Compare commits
191 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,6 +34,25 @@ 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 ---
|
||||||
# Resolved against the project root in src/backend/config.py.
|
# Resolved against the project root in src/backend/config.py.
|
||||||
@@ -34,12 +63,40 @@ 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) ---
|
# --- AI (OpenRouter) ---
|
||||||
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
|
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
|
||||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare
|
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
|
||||||
|
|||||||
+9
-10
@@ -33,16 +33,12 @@ env/
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
desktop.ini
|
desktop.ini
|
||||||
|
|
||||||
# Uploads (server-side files, now at project root)
|
# Uploads (server-side files, mounted as a volume in production): ignora tutto
|
||||||
uploads/images/*
|
# sotto uploads/, comprese le sottocartelle create a runtime (vision reference
|
||||||
uploads/pdfs/*
|
# images, cartelle per ricetta/versione) — tieni solo i .gitkeep che mantengono
|
||||||
uploads/logos/*
|
# le cartelle vuote in git.
|
||||||
uploads/reports/*
|
uploads/*
|
||||||
uploads/general/
|
!uploads/**/.gitkeep
|
||||||
!uploads/images/.gitkeep
|
|
||||||
!uploads/pdfs/.gitkeep
|
|
||||||
!uploads/logos/.gitkeep
|
|
||||||
!uploads/reports/.gitkeep
|
|
||||||
|
|
||||||
# TailwindCSS output
|
# TailwindCSS output
|
||||||
src/frontend/flask_app/static/css/tailwind.css
|
src/frontend/flask_app/static/css/tailwind.css
|
||||||
@@ -74,3 +70,6 @@ nul
|
|||||||
# Competitor analysis (local only)
|
# Competitor analysis (local only)
|
||||||
Concorrente/
|
Concorrente/
|
||||||
docker-compose.override.yml
|
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 @@
|
|||||||
|
*
|
||||||
@@ -7,9 +7,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
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 **backend FastAPI** (porta 8000) e **frontend Flask** (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 (V2.0.0)
|
## Layout del repository (V3.0.0)
|
||||||
|
|
||||||
A partire dalla migrazione V2.0.0 (struttura conforme alla spec `python-project-spec-design.md`):
|
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/
|
TieMeasureFlow/
|
||||||
@@ -18,10 +18,13 @@ TieMeasureFlow/
|
|||||||
├── .python-version # 3.11
|
├── .python-version # 3.11
|
||||||
├── Dockerfile # Backend (uv + uvicorn)
|
├── Dockerfile # Backend (uv + uvicorn)
|
||||||
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||||
|
├── Dockerfile.vision # Worker di visione (uv, Python 3.13)
|
||||||
├── docker-compose.dev.yml # Dev (Nginx)
|
├── docker-compose.dev.yml # Dev (Nginx)
|
||||||
├── docker-compose.yml # Prod (Traefik + SSL)
|
├── docker-compose.yml # Prod (Traefik + SSL)
|
||||||
├── nginx/
|
├── nginx/
|
||||||
├── uploads/ # Volume montato in /app/uploads
|
├── uploads/ # Volume montato in /app/uploads
|
||||||
|
├── vendor/
|
||||||
|
│ └── visionsuite/ # Submodule: libreria di visione (vs-core, vs-task, vs-measure, vs-pm2d)
|
||||||
├── docs/
|
├── docs/
|
||||||
└── src/
|
└── src/
|
||||||
├── backend/
|
├── backend/
|
||||||
@@ -38,16 +41,18 @@ TieMeasureFlow/
|
|||||||
│ ├── migrations/ # Alembic
|
│ ├── migrations/ # Alembic
|
||||||
│ ├── templates/ # Setup page
|
│ ├── templates/ # Setup page
|
||||||
│ └── tests/ # pytest
|
│ └── tests/ # pytest
|
||||||
└── frontend/
|
├── frontend/
|
||||||
└── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
│ └── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
||||||
├── app.py # giustificata: tablet UX server-side, USB calipers,
|
│ ├── app.py # giustificata: tablet UX server-side, USB calipers,
|
||||||
├── config.py # workflow operatore con Fabric.js)
|
│ ├── config.py # workflow operatore con Fabric.js)
|
||||||
├── blueprints/
|
│ ├── blueprints/
|
||||||
├── services/
|
│ ├── services/
|
||||||
├── templates/
|
│ ├── templates/
|
||||||
├── static/
|
│ ├── static/
|
||||||
├── translations/
|
│ ├── translations/
|
||||||
└── tests/
|
│ └── 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`):
|
Dipendenze gestite con **uv** (no `requirements.txt`):
|
||||||
@@ -280,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)
|
||||||
|
|||||||
+18
-3
@@ -1,7 +1,7 @@
|
|||||||
FROM python:3.11-slim AS base
|
FROM python:3.11.16-slim AS base
|
||||||
|
|
||||||
# Install uv (fast Python package manager) from official slim image.
|
# Install uv (fast Python package manager) from official slim image.
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /usr/local/bin/
|
||||||
|
|
||||||
# System libs required by WeasyPrint at runtime.
|
# System libs required by WeasyPrint at runtime.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -13,6 +13,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
shared-mime-info \
|
shared-mime-info \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy lockfile + project metadata first to maximize Docker layer cache.
|
# Copy lockfile + project metadata first to maximize Docker layer cache.
|
||||||
@@ -27,7 +39,10 @@ RUN uv sync --frozen --no-dev --extra server
|
|||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
|
|
||||||
# Uploads directory (mounted as a volume in production).
|
# Uploads directory (mounted as a volume in production).
|
||||||
RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports
|
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
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
+43
-14
@@ -1,13 +1,34 @@
|
|||||||
FROM python:3.11-slim AS base
|
# 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).
|
# uv from the official slim image (fast Python package manager).
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /usr/local/bin/
|
||||||
|
|
||||||
# Node.js 20 is needed at build time to compile TailwindCSS.
|
# Non-root user the app runs as at runtime. -m creates a home dir: `uv run`
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
# in the CMD below writes its cache under $HOME, and without one it has
|
||||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
# nowhere writable to put it.
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
RUN useradd -m -u 10001 app
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
# 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
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -16,16 +37,24 @@ COPY pyproject.toml uv.lock ./
|
|||||||
COPY .python-version ./
|
COPY .python-version ./
|
||||||
RUN uv sync --frozen --no-dev --extra client
|
RUN uv sync --frozen --no-dev --extra client
|
||||||
|
|
||||||
# Copy the Flask app sources.
|
# 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 src/frontend/flask_app/ ./flask_app/
|
||||||
|
COPY --from=tailwind-build /app/flask_app/static/css/tailwind.css ./flask_app/static/css/tailwind.css
|
||||||
# Build TailwindCSS (one-shot; no watcher in production image).
|
|
||||||
WORKDIR /app/flask_app
|
|
||||||
RUN npm install tailwindcss@3 && \
|
|
||||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
|
||||||
|
|
||||||
# Compile Flask-Babel translation catalogs.
|
# Compile Flask-Babel translation catalogs.
|
||||||
RUN uv run --project /app pybabel compile -d translations
|
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
|
EXPOSE 5000
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
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.
|
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:** V2.0.0 (in sviluppo) — branch default `V2.0.0`.
|
> **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).
|
> 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,6 +26,32 @@ Caratteristiche principali:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architettura
|
## Architettura
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -67,13 +94,22 @@ Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al bac
|
|||||||
| Flask | 3.x | Framework web server-side |
|
| Flask | 3.x | Framework web server-side |
|
||||||
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
| 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) | Reattività 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 |
|
||||||
| html5-qrcode | CDN | Scanner barcode/QR camera |
|
| 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
|
### Tooling
|
||||||
|
|
||||||
| Componente | Ruolo |
|
| Componente | Ruolo |
|
||||||
@@ -91,16 +127,25 @@ Il frontend Flask non espone mai le credenziali al browser: ogni chiamata al bac
|
|||||||
Docker Compose è il metodo raccomandato. Gestisce database, migrazioni, build delle immagini con `uv` 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 ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
# 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, STATION_CODE per ogni tablet
|
# 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 --build
|
# (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
|
||||||
@@ -211,7 +256,8 @@ I ruoli sono combinabili (array JSON per utente). Il flag `is_admin` è separato
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **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. Vede solo le ricette assegnate alla propria stazione (`STATION_CODE`) |
|
| **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 |
|
||||||
|
| **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** |
|
| **Admin** (flag) | Gestione sistema: CRUD utenti, cambio password, attivazione/disattivazione account, **CRUD stazioni e assegnazioni ricette** |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -229,6 +275,7 @@ TieMeasureFlow/
|
|||||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||||
├── nginx/ # Config Nginx (dev)
|
├── nginx/ # Config Nginx (dev)
|
||||||
├── uploads/ # Volume Docker file caricati
|
├── uploads/ # Volume Docker file caricati
|
||||||
|
├── scripts/ # Script del progetto (seed ricette di collaudo)
|
||||||
├── docs/ # Documentazione (vedi indice docs/README.md)
|
├── docs/ # Documentazione (vedi indice docs/README.md)
|
||||||
└── src/
|
└── src/
|
||||||
├── backend/ # FastAPI Backend
|
├── backend/ # FastAPI Backend
|
||||||
@@ -245,11 +292,14 @@ TieMeasureFlow/
|
|||||||
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
||||||
│ │ │ # AccessLog, SystemSetting,
|
│ │ │ # AccessLog, SystemSetting,
|
||||||
│ │ │ # RecipeVersionAudit, Station,
|
│ │ │ # RecipeVersionAudit, Station,
|
||||||
│ │ │ # StationRecipeAssignment
|
│ │ │ # StationRecipeAssignment,
|
||||||
|
│ │ │ # ProductionRun, ProductionEvent
|
||||||
│ │ └── api/ # Pydantic v2 schemas request/response
|
│ │ └── api/ # Pydantic v2 schemas request/response
|
||||||
│ ├── services/ # recipe_service, measurement_service,
|
│ ├── services/ # recipe_service, measurement_service,
|
||||||
│ │ # spc_service, report_service,
|
│ │ # spc_service, report_service,
|
||||||
│ │ # auth_service, station_service
|
│ │ # auth_service, station_service,
|
||||||
|
│ │ # production_service,
|
||||||
|
│ │ # production_export_service
|
||||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||||
│ ├── templates/ # Pagina setup (Jinja2)
|
│ ├── templates/ # Pagina setup (Jinja2)
|
||||||
│ └── tests/ # pytest + httpx + aiosqlite
|
│ └── tests/ # pytest + httpx + aiosqlite
|
||||||
@@ -262,9 +312,13 @@ TieMeasureFlow/
|
|||||||
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
||||||
├── templates/ # Jinja2 + Alpine.js
|
├── templates/ # Jinja2 + Alpine.js
|
||||||
├── static/
|
├── static/
|
||||||
│ ├── css/ # TailwindCSS compilato
|
│ ├── css/ # TailwindCSS compilato + themes.css
|
||||||
│ └── js/ # numpad, caliper, barcode, csv-export,
|
│ │ # (cornice pagina, temi, scrollbar)
|
||||||
│ # spc-charts, annotation-editor/viewer
|
│ ├── 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
|
├── translations/ # Flask-Babel .po/.mo IT/EN
|
||||||
└── tests/
|
└── tests/
|
||||||
```
|
```
|
||||||
@@ -279,6 +333,7 @@ TieMeasureFlow/
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
| `docker compose -f docker-compose.dev.yml 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 |
|
||||||
@@ -341,7 +396,98 @@ uv run pytest --cov src/backend
|
|||||||
uv run pytest src/frontend/flask_app/tests/
|
uv run pytest src/frontend/flask_app/tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
Stato corrente: **183 pass, 0 fail** (vedi `docs/architecture/STATO_PROGETTO.md`).
|
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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -359,6 +505,7 @@ Copia `.env.example` in `.env` e configura:
|
|||||||
| `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`) |
|
||||||
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
| `STATION_CODE` | **Per-tablet** — codice stazione (es. `ST-001`). Senza, il client mostra errore configurazione. |
|
||||||
|
| `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) |
|
||||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||||
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||||
@@ -377,6 +524,7 @@ Indice completo: [`docs/README.md`](docs/README.md).
|
|||||||
|
|
||||||
| Documento | Contenuto |
|
| Documento | Contenuto |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [`TieMeasureFlow_modifiche_2026-07-28.md`](TieMeasureFlow_modifiche_2026-07-28.md) | I quindici punti del 28/07: cosa deve cambiare, dove intervenire, decisioni in attesa del cliente (D-1…D-9) |
|
||||||
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
| [`docs/architecture/STATO_PROGETTO.md`](docs/architecture/STATO_PROGETTO.md) | Snapshot V2.0.0: cosa funziona oggi, test status, decisioni architetturali |
|
||||||
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
| [`docs/architecture/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
||||||
|
|
||||||
@@ -388,6 +536,8 @@ Indice completo: [`docs/README.md`](docs/README.md).
|
|||||||
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
| [`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/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/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
|
### Piani dettagliati
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+49
-3
@@ -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
|
||||||
@@ -31,13 +32,42 @@ services:
|
|||||||
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
|
||||||
|
|
||||||
@@ -45,14 +75,28 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
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
|
||||||
|
|
||||||
|
|||||||
+55
-8
@@ -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
|
||||||
@@ -31,52 +32,98 @@ services:
|
|||||||
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: .
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
+180
-281
@@ -142,6 +142,19 @@ SSL_KEYFILE=
|
|||||||
| `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
|
||||||
@@ -169,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`
|
||||||
@@ -234,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)
|
||||||
@@ -280,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
|
||||||
@@ -315,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`
|
||||||
@@ -328,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)
|
||||||
@@ -350,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
|
||||||
@@ -373,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
|
||||||
@@ -386,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 │
|
┌─────▼─────┐ ┌──────▼─────┐
|
||||||
└──────┘ └──────┘
|
│ server │ │ client │
|
||||||
│ │
|
│ :8000 │ │ :5000 │
|
||||||
└────┬─────┘
|
└─────┬──────┘ └────────────┘
|
||||||
│
|
│
|
||||||
┌────▼────┐
|
┌─────▼──────┐ ┌─────────────┐
|
||||||
│ MySQL │
|
│ vision │ │ MySQL 8.0 │
|
||||||
│ :3306 │
|
│ :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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -644,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
|
||||||
@@ -667,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
|
||||||
@@ -678,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
|
||||||
@@ -703,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
-3
@@ -6,8 +6,11 @@ Indice della documentazione del progetto.
|
|||||||
|
|
||||||
| Documento | Scopo |
|
| Documento | Scopo |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V2.0.0). Snapshot del sistema, componenti e capacità. |
|
| [`../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/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta da fare. Fasi 2-7 della migrazione rev04 verso V1.1.0/M1 demo cliente. |
|
| [`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
|
## Riferimenti operativi
|
||||||
|
|
||||||
@@ -17,8 +20,12 @@ Indice della documentazione del progetto.
|
|||||||
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). |
|
| [`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). |
|
| [`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). |
|
| [`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)
|
## 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 |
|
| Documento | Scopo |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -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.
|
||||||
+126
-75
@@ -1,95 +1,146 @@
|
|||||||
# Roadmap TieMeasureFlow — V2.0.0 → V1.1.0 (rev04 / M1 demo cliente)
|
# Roadmap TieMeasureFlow — V3.0.0 → installazione a Tràfilo
|
||||||
|
|
||||||
> Aggiornare ad ogni Fase chiusa.
|
> Aggiornata al 2026-07-28. Aggiornare a ogni punto chiuso.
|
||||||
|
|
||||||
## Riferimenti
|
## Dove siamo
|
||||||
|
|
||||||
- Master plan dettagliato: [`../superpowers/plans/2026-04-17-rev04-master-roadmap.md`](../superpowers/plans/2026-04-17-rev04-master-roadmap.md)
|
La roadmap rev04 di aprile (Fasi 1-7, milestone M1/M2) è **superata dai fatti**: il
|
||||||
- Spec sorgente: [`../specs/2026-04-16-schema-sviluppo-rev04.docx`](../specs/2026-04-16-schema-sviluppo-rev04.docx)
|
sopralluogo del 28/07 ha prodotto un elenco di quindici punti concreti che è oggi il
|
||||||
- Stato corrente: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
|
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.
|
||||||
|
|
||||||
## Strategia: due milestone
|
- 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)
|
||||||
|
|
||||||
| Milestone | Scope | Obiettivo |
|
**Scadenza che comanda tutto: l'installazione on-premise a Tràfilo è prevista per
|
||||||
|
settembre 2026.**
|
||||||
|
|
||||||
|
## I quindici punti
|
||||||
|
|
||||||
|
| # | Punto | Stato |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **M1 — Demo cliente** | Fasi 1-5 + deploy "demo" | Sistema testabile end-to-end col cliente per raccogliere feedback |
|
| 1 | Memoria della produzione in corso | ✅ fatto |
|
||||||
| **M2 — Produzione** | Fasi 6-7 + correzioni post-feedback + GAIA live | Rollout su tablet/PC reali |
|
| 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 |
|
||||||
|
|
||||||
## Stato Fasi (M1)
|
## Cosa resta, in ordine
|
||||||
|
|
||||||
| Fase | Scope | Stato | Branch / Commit |
|
### 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 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **1** | Stazioni + identità per-tablet | ✅ **COMPLETATA** | `V2.0.0` (merge `ea8e468` da `feature/rev04-phase1-stations`) |
|
| **D-1** | Protocollo del gestionale GAIA: servizi web, database condiviso, file? | innesto GAIA (punto 6) | IT Tràfilo + fornitore GAIA |
|
||||||
| 2 | Ruolo Capoturno (Supervisor) + override token breve | ⏳ Da iniziare | — |
|
| **D-2** | Rete e credenziali per raggiungere GAIA | come sopra | IT Tràfilo |
|
||||||
| 3 | Editor ricetta a blocchi (preparation + measurement) | ⏳ Da iniziare | — |
|
| ~~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 |
|
||||||
| 4 | Workflow operatore (retry/timer/autologout/avvio produzione) | ⏳ Da iniziare | — |
|
| **D-4** | Server: quale macchina, quanto spazio disco (database **e** disegni) | installazione | IT Tràfilo |
|
||||||
| 5 (M1) | `ImportOnlyGaiaClient` + UI import dati cliente reali | ⏳ Da iniziare | — |
|
| **D-5** | Cicalino: basta il suono del browser o serve una colonnina luminosa? | punto 3 (completamento) | Tràfilo |
|
||||||
| Deploy M1 | VPS demo (compose + Traefik + LE, no registry) | ⏳ Da iniziare | — |
|
| **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 |
|
||||||
|
|
||||||
## Stato Fasi (M2)
|
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.
|
||||||
|
|
||||||
| Fase | Scope | Stato |
|
## Tech debt
|
||||||
|---|---|---|
|
|
||||||
| 5 (M2) | GAIA reale (protocollo TBD, polling, comandi produzione) | ⏳ Bloccata da decisioni cliente (D-0.1, D-0.2) |
|
|
||||||
| 6 | Deploy B industriale (registry privato + Watchtower + STATION_ID per-tablet + CI release) | ⏳ Pianificata |
|
|
||||||
| 7 | Hardening, security review, E2E sito pilota, docs aggiornati, i18n delta | ⏳ Pianificata |
|
|
||||||
|
|
||||||
## Decisioni aperte (bloccanti per M2 / future fasi)
|
|
||||||
|
|
||||||
Da: master plan §0 "Precondizioni e Decisioni Aperte". Da risolvere col cliente prima della Fase 5/6.
|
|
||||||
|
|
||||||
| ID | Decisione | Stato | Bloccante per |
|
|
||||||
|---|---|---|---|
|
|
||||||
| D-0.1 | Protocollo integrazione GAIA (REST / DB shared / OPC-UA / file) | **Aperta** | Fase 5 reale (M2) |
|
|
||||||
| D-0.2 | Credenziali e rete GAIA (VPN / firewall / whitelist IP) | **Aperta** | Fase 5 reale (M2) |
|
|
||||||
| D-0.3 | Target hardware "tablet" (Windows / Linux industriale / Android) | **Aperta** | Fase 6 (deploy B) |
|
|
||||||
| D-0.4 | Cicalino/luce avviso (audio HTML5 / hardware USB / entrambi) | **Rimandata a M2** | Fase 4 finale |
|
|
||||||
| D-0.5 | Parametri runtime modificabili vs versione immutabile | **Aperta** (raccomandato B: separare volatili) | Fase 3 |
|
|
||||||
| D-0.6 | Auth capoturno durante override (modale / PIN / RFID) | **Aperta** | Fase 2 |
|
|
||||||
| D-0.7 | Timeout auto-logout | **Risolta** | — |
|
|
||||||
| D-0.8 | Naming ruolo capoturno | **Proposta:** `Supervisor` | Fase 2 |
|
|
||||||
| D-0.9 | Tag versione immagine docker | **Proposta:** SemVer + `latest` | Fase 6 |
|
|
||||||
| D-0.10 | Registry esposto su Internet o solo VPN | **Proposta:** solo VPN cliente | Fase 6 |
|
|
||||||
|
|
||||||
## Tech debt da chiudere
|
|
||||||
|
|
||||||
| Item | Priorità | Note |
|
| Item | Priorità | Note |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 3 test backend pre-esistenti rotti (`test_recipes`, `test_tasks`) | Media | Investigare prima di Fase 3 (toccano recipe + task router). |
|
| `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/` |
|
||||||
| 1 test client pre-esistente rotto (`test_save_measurement_proxy`) | Bassa | Probabilmente CSRF/payload. Risolvere con Fase 4. |
|
| 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 |
|
||||||
| Pagina `task_complete` riepilogo: utente segnala riga vuota in alcuni scenari | Media | Da debuggare (rendering corretto via curl ma utente vede vuoto in browser, possibile interazione con sessione lot/serial). |
|
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato: impatta i deploy esistenti |
|
||||||
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato (impatto deploy). |
|
| Header `X-API-Key` → `X-Api-Key` | Bassa | Breaking per i deploy esistenti |
|
||||||
| Header `X-API-Key` rename a `X-Api-Key` | Bassa | Vedere se M2 lo richiede. |
|
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 |
|
||||||
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 in M2. |
|
| Test di carico a 20 tablet reali | Bassa | La capacità è dimensionata ma mai misurata sotto carico vero |
|
||||||
| `Dockerfile.frontend`: `pybabel compile` via `uv run` non testato in build reale | Alta | Verificare al primo `docker compose build`. |
|
|
||||||
| Smoke test in container Docker (non solo locale uvicorn+gunicorn) | Alta | Validare che i Dockerfile riscritti con `uv` buildino e girino correttamente prima di chiudere V2.0.0. |
|
|
||||||
|
|
||||||
## Open per scelta utente prima della prossima sessione
|
I quattro test rotti tracciati nello snapshot V2.0.0 non risultano più: la suite è a
|
||||||
|
360 pass, 0 fail.
|
||||||
|
|
||||||
1. **Quale fase iniziare adesso?** Opzioni: Fase 2 (Supervisor — sblocca workflow), Fase 3 (block editor — independente), Fase 5 (import GAIA dati reali — sblocca demo).
|
## Mappatura con la roadmap rev04 di aprile
|
||||||
2. **Revisione decisioni aperte col cliente** — D-0.1 / D-0.2 / D-0.3 / D-0.6 prima di pianificare Fase 5 e 6.
|
|
||||||
3. **Smoke test Docker** della nuova struttura V2.0.0 (`docker compose -f docker-compose.dev.yml up --build`) per validare i Dockerfile riscritti.
|
|
||||||
4. **Test di carico** (k6/locust) a 20 VU su `/measure/save-measurement` per validare la scalatura worker (capacità annunciata: 20-30 tablet contemporanei).
|
|
||||||
|
|
||||||
## Stima tempi residui M1 (post-Fase 1)
|
Per chi torna sui documenti di aprile e non ritrova le Fasi.
|
||||||
|
|
||||||
| Task | Stima full-time |
|
| Fase rev04 | Che fine ha fatto |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Fase 2 — Supervisor + override | 1 settimana |
|
| 1 — Stazioni per-tablet | Chiusa in V2.0.0, ampliata dal punto 7 |
|
||||||
| Fase 3 — Block editor | 1.5 settimane |
|
| 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 |
|
||||||
| Fase 4 — Workflow operatore | 2 settimane |
|
| 3 — Editor ricetta a blocchi | Sostituita dal punto 2 (tipo di task dichiarato), che risolve il problema vero senza riscrivere l'editor |
|
||||||
| Fase 5 (M1) — Import-only GAIA | 1 settimana |
|
| 4 — Workflow operatore | Assorbita dai punti 3, 4, 10: timer e sequenza sono fatti, i tentativi sono il punto 4 |
|
||||||
| Deploy M1 demo | 0.5 settimane |
|
| 5 — Import GAIA | Diventata l'innesto del punto 6, ferma su D-1 e D-2 |
|
||||||
| **Totale M1 residuo** | **~6 settimane** |
|
| 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 |
|
||||||
|
|
||||||
## Stima tempi M2 (dopo feedback)
|
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) è
|
||||||
| Task | Stima |
|
chiusa: si chiama `Supervisor`. D-0.7 (auto-logout) era già risolta.
|
||||||
|---|---|
|
|
||||||
| Aggiustamenti post-feedback | variabile (1-2 sett.) |
|
|
||||||
| Fase 5 reale GAIA | 1-2 settimane |
|
|
||||||
| Fase 6 deploy B | 1 settimana |
|
|
||||||
| Fase 7 hardening | 1-2 settimane |
|
|
||||||
| **Totale M2** | **~4-7 settimane** |
|
|
||||||
|
|
||||||
**Totale fino a produzione:** ~10-13 settimane full-time da oggi (2026-04-25), assumendo decisioni aperte risolte in tempo utile.
|
|
||||||
|
|||||||
+157
-110
@@ -1,145 +1,192 @@
|
|||||||
# Stato Progetto TieMeasureFlow — V2.0.0
|
# Stato Progetto TieMeasureFlow — V3.0.0
|
||||||
|
|
||||||
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone.
|
> 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
|
## Versione corrente
|
||||||
|
|
||||||
**V2.0.0** (in sviluppo, branch `V2.0.0` come default su `git.tielogic.xyz`).
|
**V3.0.0** (in sviluppo, branch `V3.0.0`). Versione precedente: `V2.0.0`.
|
||||||
|
|
||||||
Versione precedente di produzione: `V1.0.7`.
|
In esercizio su `tieflow.tielogic.xyz` (Docker Compose + Traefik + Let's Encrypt),
|
||||||
|
schema alla migrazione `010_meas_authorisation`.
|
||||||
|
|
||||||
## Sintesi esecutiva
|
## 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/`).
|
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).
|
||||||
|
|
||||||
## Cosa funziona oggi (V2.0.0 — branch corrente)
|
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.
|
||||||
|
|
||||||
### Funzionalità ereditate da V1.0.7
|
Tredici punti su quindici sono fatti e in esercizio. Uno è fermo sulle risposte del
|
||||||
- Autenticazione username/password + API key per-utente, ruoli combinabili (Maker, MeasurementTec, Metrologist) + flag `is_admin`.
|
cliente, uno è fuori offerta.
|
||||||
- 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)
|
## I quindici punti
|
||||||
- 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)
|
| # | Punto | Stato | Dove vive |
|
||||||
- Gunicorn 5 workers × 4 thread (gthread) — capacità ~20 richieste concorrenti Flask, regge 20+ tablet.
|
|---|---|---|---|
|
||||||
- Uvicorn 4 workers + `--proxy-headers --forwarded-allow-ips='*'`.
|
| 1 | Memoria della produzione in corso | ✅ | `production_runs`, `production_service` |
|
||||||
- Rate limit middleware: identificazione IP reale via `X-Forwarded-For` → `X-Real-IP` → `request.client.host`.
|
| 2 | Tipo di task esplicito | ✅ | `RecipeTask.task_type`, migrazione 007 |
|
||||||
- Rate limit general 100 → 300 req/min/IP (per-tablet ora, non più condiviso).
|
| 3 | Loop di misura e ripetizione | ✅ | `production-clock.js`, `production_service`, migrazione 008 |
|
||||||
- Flask `ProxyFix(x_for=1, x_proto=1, x_host=1)` per IP reale dietro Nginx.
|
| 4 | Limite di tentativi prima del capoturno | ⛔ **Fermo su D-6, D-7, D-9** | — |
|
||||||
- `APIClient` propaga `X-Forwarded-For` + `X-Real-IP` (sia JSON che multipart).
|
| 5 | Avanzamento solo se in tolleranza | ✅ | `measurement_service.pending_authorisation`, migrazione 010 |
|
||||||
- 12 test aggiuntivi (7 server + 5 client).
|
| 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 |
|
||||||
|
|
||||||
### Aggiunte V2.0.0 (struttura monorepo)
|
## Cosa è entrato in V3.0.0
|
||||||
- `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)
|
### Produzione come stato, non come pulsante (punti 1, 6)
|
||||||
|
|
||||||
Sequenza di smoke test in locale (uvicorn + gunicorn + MySQL Docker) ha fatto emergere quattro regressioni che sarebbero rimaste invisibili al test suite:
|
- 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.
|
||||||
|
|
||||||
- **`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"`.
|
### Il ciclo di misura (punto 3)
|
||||||
- **`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).
|
- 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.
|
||||||
|
|
||||||
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.
|
### Le regole della ricetta (punti 8, 9)
|
||||||
|
|
||||||
## Layout repository (V2.0.0)
|
- `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)
|
||||||
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
|
- 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.
|
||||||
|
|
||||||
Validazione end-to-end in locale (2026-04-26):
|
### L'interfaccia dell'operatore (punti 10, 11, 14, 15)
|
||||||
|
|
||||||
- ✅ MySQL container Docker up, schema creato, alembic stamp head OK
|
- La ricetta si apre sul primo task (`/measure/start/<id>`), non su un elenco. La
|
||||||
- ✅ uvicorn `--reload` su :8000, `/api/health` risponde
|
lista scende a secondo livello e mostra quali task sono rimasti incompiuti
|
||||||
- ✅ Seed `/api/setup/seed` con `SETUP_PASSWORD=adriano77` → admin + 4 utenti demo + DEMO-001 + ST-DEFAULT con assegnazione automatica
|
(`GET /api/measurements/task-progress`, contato per quota e non per tentativo).
|
||||||
- ✅ Login `admin/admin123` via web, sessione persistente
|
- «Fine ciclo misura» è visibile da subito e spento finché mancano quote, con
|
||||||
- ✅ `/admin/stations`: tabella, modal create/edit, modal gestione assegnazioni a 2 colonne con search, eliminazione con cascade
|
scritto quante ne mancano.
|
||||||
- ✅ `/admin/users`, `/maker/recipes`, `/measure/select` (filtrato per stazione), `/statistics/dashboard`
|
- Descrizioni con `**grassetto**` e a capo: marcatura, non HTML, così la
|
||||||
- ✅ Workflow MeasurementTec end-to-end: select_recipe → task_list → task_execute → task_complete (riepilogo con misure)
|
sanificazione è per costruzione.
|
||||||
- ✅ Hot reload Flask + uvicorn `--reload` + Tailwind watch attivi durante lo sviluppo
|
- 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 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
|
| | Test | Fail |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend (`src/backend/tests/`) | 212 | 0 |
|
||||||
|
| Frontend (`src/frontend/flask_app/tests/`) | 148 | 0 |
|
||||||
|
| **Totale** | **360** | **0** |
|
||||||
|
|
||||||
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 + WeasyPrint + Plotly/Kaleido.
|
I quattro fallimenti pre-esistenti tracciati nello snapshot V2.0.0 non ci sono più.
|
||||||
- **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.
|
Tre file di test non renderizzano niente e leggono i sorgenti, perché guardano
|
||||||
- **Tooling:** uv (package mgmt), pytest + pytest-asyncio + httpx + aiosqlite (test).
|
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
|
## Decisioni architetturali rilevanti
|
||||||
|
|
||||||
| Decisione | Stato | Note |
|
| 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. |
|
| 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. |
|
||||||
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, no microservizi. Nessuno stub `nats_client/` creato. |
|
| 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. |
|
||||||
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo 4-5gg refactor + rotture client. Eventuale v2 API in M2. |
|
| Descrizioni in marcatura, non in HTML | **Confermata** | Niente HTML accettato in ingresso: la sanificazione è per costruzione, non per filtro. |
|
||||||
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare costa 50+ punti di codice + breaking per deploy. Rivedere in M2. |
|
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, calibri USB, editor Fabric.js, i18n Babel collaudato. |
|
||||||
| Variabili `.env` (DB_HOST, SERVER_PORT, ...) | **Mantenute attuali** | Rename a SERVICE_NAME/SERVICE_DOMAIN/API_KEY rinviato (impatta deploy esistenti). |
|
| 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
|
## Branch git
|
||||||
|
|
||||||
- **Default:** `V2.0.0` (lavoro corrente)
|
- **Corrente:** `V3.0.0`
|
||||||
- **Mantenuti:** `V1.0.0` … `V1.0.7` (release branches storiche)
|
- **Precedenti:** `V2.0.0`, `V1.0.0` … `V1.0.7` (release storiche)
|
||||||
- **Mergiato e chiuso:** `feature/rev04-phase1-stations` (in `V2.0.0` con commit `ea8e468`)
|
|
||||||
|
|||||||
@@ -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`)
|
||||||
@@ -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)
|
||||||
|
|||||||
+38
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "tiemeasureflow"
|
name = "tiemeasureflow"
|
||||||
version = "2.0.0"
|
version = "3.0.0"
|
||||||
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
|
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
authors = [
|
authors = [
|
||||||
@@ -53,6 +53,34 @@ dev = [
|
|||||||
"coverage>=7.0.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]
|
[project.scripts]
|
||||||
# Backend
|
# Backend
|
||||||
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||||
@@ -70,6 +98,14 @@ packages = ["src/backend", "src/frontend"]
|
|||||||
# Pin the resolver to the deps we declared; reproducible builds.
|
# Pin the resolver to the deps we declared; reproducible builds.
|
||||||
package = false
|
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]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests"]
|
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,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 src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
|
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.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
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ 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 src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||||
from src.backend.database import async_session_factory
|
from src.backend.database import async_session_factory
|
||||||
from src.backend.models.orm.access_log import AccessLog
|
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):
|
||||||
"""Middleware that logs every API request to the access_logs table."""
|
"""Middleware that logs every API request to the access_logs table."""
|
||||||
@@ -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],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Rate limiting middleware for FastAPI.
|
"""Rate limiting middleware for FastAPI.
|
||||||
|
|
||||||
Implements in-memory sliding window rate limiting per client IP.
|
Implements in-memory sliding window rate limiting per client IP.
|
||||||
Configurable limits for login and general endpoints.
|
Configurable limits for login and endpoints that carry credentials, and for
|
||||||
|
everything else.
|
||||||
"""
|
"""
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
@@ -17,14 +19,34 @@ from src.backend.config import settings
|
|||||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
"""Middleware that enforces per-IP rate limits using a sliding window.
|
"""Middleware that enforces per-IP rate limits using a sliding window.
|
||||||
|
|
||||||
- Login endpoint (/api/auth/login): limited to `rate_limit_login` req/min.
|
- 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.
|
- All other endpoints: limited to `rate_limit_general` req/min.
|
||||||
Returns HTTP 429 with Retry-After header when limit exceeded.
|
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"
|
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
|
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:
|
def __init__(self, app) -> None:
|
||||||
super().__init__(app)
|
super().__init__(app)
|
||||||
# {ip: [timestamp, ...]} per bucket
|
# {ip: [timestamp, ...]} per bucket
|
||||||
@@ -34,23 +56,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _client_ip(request: Request) -> str:
|
def _client_ip(request: Request) -> str:
|
||||||
"""Resolve the originating client IP, honoring proxy headers.
|
"""Resolve the originating client IP behind `trusted_proxy_count` proxies.
|
||||||
|
|
||||||
Order of precedence: ``X-Forwarded-For`` (first hop), ``X-Real-IP``,
|
Nginx (and, behind it, the Flask client) each add one hop to
|
||||||
``request.client.host``. Required because Nginx and the Flask client
|
X-Forwarded-For. The *leftmost* entries are whatever the original
|
||||||
sit between the tablet and the API; without parsing these headers
|
caller claimed and are trivially spoofed; trust only extends to the
|
||||||
every tablet shares one bucket.
|
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.
|
||||||
"""
|
"""
|
||||||
xff = request.headers.get("x-forwarded-for")
|
count = settings.trusted_proxy_count
|
||||||
if xff:
|
if count > 0:
|
||||||
first = xff.split(",")[0].strip()
|
xff = request.headers.get("x-forwarded-for")
|
||||||
if first:
|
if xff:
|
||||||
return first
|
hops = [h.strip() for h in xff.split(",") if h.strip()]
|
||||||
real = request.headers.get("x-real-ip")
|
if len(hops) >= count:
|
||||||
if real:
|
return hops[-count]
|
||||||
return real.strip()
|
# 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"
|
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]:
|
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
||||||
"""Remove timestamps outside the current sliding window."""
|
"""Remove timestamps outside the current sliding window."""
|
||||||
cutoff = now - self.WINDOW_SECONDS
|
cutoff = now - self.WINDOW_SECONDS
|
||||||
@@ -97,10 +160,12 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
self._evict_stale_ips(self._login_requests, now)
|
self._evict_stale_ips(self._login_requests, now)
|
||||||
self._evict_stale_ips(self._general_requests, now)
|
self._evict_stale_ips(self._general_requests, now)
|
||||||
|
|
||||||
# Check login-specific rate limit
|
# Check login-bucket rate limit (login itself, supervisor-credential
|
||||||
if path == self.LOGIN_PATH and request.method == "POST":
|
# paths, and the setup password)
|
||||||
|
if self._is_login_bucket_request(request.method, path):
|
||||||
allowed, retry_after = self._check_rate_limit(
|
allowed, retry_after = self._check_rate_limit(
|
||||||
self._login_requests, client_ip, settings.rate_limit_login, now
|
self._login_requests, client_ip,
|
||||||
|
self._effective_limit(settings.rate_limit_login), now,
|
||||||
)
|
)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -111,7 +176,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
|
|
||||||
# Check general rate limit
|
# Check general rate limit
|
||||||
allowed, retry_after = self._check_rate_limit(
|
allowed, retry_after = self._check_rate_limit(
|
||||||
self._general_requests, client_ip, settings.rate_limit_general, now
|
self._general_requests, client_ip,
|
||||||
|
self._effective_limit(settings.rate_limit_general), now,
|
||||||
)
|
)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -10,16 +10,18 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
|
|
||||||
from src.backend.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:"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Authentication router - login, logout, profile."""
|
"""Authentication router - login, logout, profile."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
@@ -8,10 +9,17 @@ from src.backend.models.orm.user import User
|
|||||||
from src.backend.models.api.user import (
|
from src.backend.models.api.user import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
PinLoginRequest,
|
||||||
|
PinUserItem,
|
||||||
UserProfileUpdate,
|
UserProfileUpdate,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
|
from src.backend.services.auth_service import (
|
||||||
|
authenticate_pin,
|
||||||
|
authenticate_user,
|
||||||
|
login_user,
|
||||||
|
logout_user,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -32,13 +40,47 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def logout(
|
async def logout(
|
||||||
|
request: Request,
|
||||||
|
all_sessions: bool = False,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Logout - invalidate API key."""
|
"""Logout. Deletes this session's API key only; ?all_sessions=true
|
||||||
await logout_user(db, user)
|
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)
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.config import settings
|
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.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
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -20,6 +26,93 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
|||||||
ALLOWED_PDF_TYPE = "application/pdf"
|
ALLOWED_PDF_TYPE = "application/pdf"
|
||||||
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
|
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:
|
def validate_file_type(content_type: str) -> bool:
|
||||||
"""Validate if file type is allowed."""
|
"""Validate if file type is allowed."""
|
||||||
@@ -119,25 +212,36 @@ async def upload_file(
|
|||||||
"""Upload an image or PDF file.
|
"""Upload an image or PDF file.
|
||||||
|
|
||||||
Files are stored in uploads/{recipe_id}/{version_id}/
|
Files are stored in uploads/{recipe_id}/{version_id}/
|
||||||
Thumbnails are generated for images.
|
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.
|
||||||
"""
|
"""
|
||||||
# Validate file type
|
# Read in chunks, stopping as soon as the size limit is crossed instead
|
||||||
if not validate_file_type(file.content_type):
|
# of buffering an oversized body into memory first.
|
||||||
raise HTTPException(
|
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
buffer = bytearray()
|
||||||
detail=f"File type {file.content_type} not allowed. Must be image or PDF.",
|
while True:
|
||||||
)
|
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
|
||||||
|
if not chunk:
|
||||||
# Read file content
|
break
|
||||||
content = await file.read()
|
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)
|
file_size = len(content)
|
||||||
|
|
||||||
# Validate file size
|
# Validate file type from the actual bytes, not file.content_type
|
||||||
if not validate_file_size(file_size):
|
detected = detect_file_type(content)
|
||||||
|
if detected is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
|
detail="File type not allowed. Must be a recognised image or PDF.",
|
||||||
)
|
)
|
||||||
|
detected_mime, detected_ext = detected
|
||||||
|
|
||||||
# Determine storage path
|
# Determine storage path
|
||||||
if recipe_id is not None and version_id is not None:
|
if recipe_id is not None and version_id is not None:
|
||||||
@@ -147,18 +251,17 @@ async def upload_file(
|
|||||||
|
|
||||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Sanitize the uploaded filename
|
# 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 "")
|
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
|
# Generate unique filename if file already exists
|
||||||
file_path = storage_dir / safe_filename
|
file_path = storage_dir / safe_filename
|
||||||
counter = 1
|
counter = 1
|
||||||
while file_path.exists():
|
while file_path.exists():
|
||||||
name_parts = safe_filename.rsplit(".", 1)
|
file_path = storage_dir / f"{base_name}_{counter}{detected_ext}"
|
||||||
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
|
counter += 1
|
||||||
|
|
||||||
# Write file
|
# Write file
|
||||||
@@ -166,7 +269,7 @@ async def upload_file(
|
|||||||
|
|
||||||
# Generate thumbnail for images
|
# Generate thumbnail for images
|
||||||
thumbnail_path = None
|
thumbnail_path = None
|
||||||
if file.content_type in ALLOWED_IMAGE_TYPES:
|
if detected_mime in ALLOWED_IMAGE_TYPES:
|
||||||
thumbnail_dir = storage_dir / "thumbnails"
|
thumbnail_dir = storage_dir / "thumbnails"
|
||||||
thumbnail_dir.mkdir(exist_ok=True)
|
thumbnail_dir.mkdir(exist_ok=True)
|
||||||
thumbnail_path = thumbnail_dir / file_path.name
|
thumbnail_path = thumbnail_dir / file_path.name
|
||||||
@@ -183,7 +286,7 @@ async def upload_file(
|
|||||||
return {
|
return {
|
||||||
"file_path": str(relative_path).replace("\\", "/"),
|
"file_path": str(relative_path).replace("\\", "/"),
|
||||||
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
|
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
|
||||||
"file_type": file.content_type,
|
"file_type": detected_mime,
|
||||||
"file_size": file_size,
|
"file_size": file_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +298,14 @@ async def get_file(
|
|||||||
):
|
):
|
||||||
"""Serve a file from the uploads directory.
|
"""Serve a file from the uploads directory.
|
||||||
|
|
||||||
Requires authentication but no specific role.
|
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)
|
full_path = resolve_upload_path(file_path)
|
||||||
return FileResponse(full_path)
|
return FileResponse(full_path)
|
||||||
|
|
||||||
@@ -205,13 +314,29 @@ async def get_file(
|
|||||||
async def delete_file(
|
async def delete_file(
|
||||||
file_path: str,
|
file_path: str,
|
||||||
user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Delete a file from the uploads directory.
|
"""Delete a file from the uploads directory.
|
||||||
|
|
||||||
Also deletes associated thumbnail if it exists.
|
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)
|
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
|
# Delete thumbnail if it exists
|
||||||
if full_path.parent.name != "thumbnails":
|
if full_path.parent.name != "thumbnails":
|
||||||
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
|
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
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
|
||||||
@@ -19,12 +21,17 @@ from src.backend.models.orm.recipe import RecipeVersion
|
|||||||
from src.backend.models.orm.setting import SystemSetting
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.measurement import (
|
from src.backend.models.api.measurement import (
|
||||||
|
CurrentCycleResponse,
|
||||||
|
MeasurementAuthorisation,
|
||||||
MeasurementBatchCreate,
|
MeasurementBatchCreate,
|
||||||
MeasurementCreate,
|
MeasurementCreate,
|
||||||
MeasurementListResponse,
|
MeasurementListResponse,
|
||||||
MeasurementResponse,
|
MeasurementResponse,
|
||||||
|
TaskProgressListResponse,
|
||||||
)
|
)
|
||||||
|
from src.backend.services import auth_service, measurement_service
|
||||||
from src.backend.services.measurement_service import save_measurement
|
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"])
|
||||||
|
|
||||||
@@ -47,6 +54,7 @@ async def create_measurement(
|
|||||||
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,
|
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:
|
||||||
@@ -56,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,
|
||||||
@@ -76,6 +175,7 @@ async def create_measurement_batch(
|
|||||||
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,
|
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]
|
||||||
@@ -96,6 +196,7 @@ def _build_measurement_filters(
|
|||||||
date_from: datetime | None,
|
date_from: datetime | None,
|
||||||
date_to: datetime | None,
|
date_to: datetime | None,
|
||||||
pass_fail: str | None,
|
pass_fail: str | None,
|
||||||
|
production_run_id: int | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
||||||
filters = []
|
filters = []
|
||||||
@@ -121,6 +222,8 @@ def _build_measurement_filters(
|
|||||||
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
|
return filters
|
||||||
|
|
||||||
|
|
||||||
@@ -132,9 +235,10 @@ async def get_measurements(
|
|||||||
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)$"),
|
||||||
|
production_run_id: int | None = Query(None, gt=0),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
per_page: int = Query(50, ge=1, le=500),
|
per_page: int = Query(50, ge=1, le=500),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
@@ -150,7 +254,7 @@ async def get_measurements(
|
|||||||
"""
|
"""
|
||||||
filters = _build_measurement_filters(
|
filters = _build_measurement_filters(
|
||||||
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||||
serial_number, date_from, date_to, pass_fail,
|
serial_number, date_from, date_to, pass_fail, production_run_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build query
|
# Build query
|
||||||
@@ -189,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",
|
||||||
@@ -265,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,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -278,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)
|
||||||
@@ -9,6 +9,7 @@ from src.backend.api.middleware.api_key import get_current_user, require_maker,
|
|||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.recipe import (
|
from src.backend.models.api.recipe import (
|
||||||
RecipeCreate,
|
RecipeCreate,
|
||||||
|
RecipeDuplicate,
|
||||||
RecipeListResponse,
|
RecipeListResponse,
|
||||||
RecipeResponse,
|
RecipeResponse,
|
||||||
RecipeUpdate,
|
RecipeUpdate,
|
||||||
@@ -52,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),
|
||||||
@@ -189,6 +202,11 @@ async def parse_technical_sheet(
|
|||||||
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
|
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(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:
|
except Exception as e:
|
||||||
logging.getLogger(__name__).error("AI parsing error: %s", e)
|
logging.getLogger(__name__).error("AI parsing error: %s", e)
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""System settings router - read/update settings, upload company logo."""
|
"""System settings router - read/update settings, upload company logo."""
|
||||||
from pathlib import Path
|
import json
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -8,11 +9,88 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
from src.backend.database import get_db
|
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.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.setting import SystemSetting
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
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("/")
|
@router.get("/")
|
||||||
async def get_settings(
|
async def get_settings(
|
||||||
@@ -49,6 +127,8 @@ async def update_settings(
|
|||||||
updated_keys = []
|
updated_keys = []
|
||||||
|
|
||||||
for key, value in settings_data.items():
|
for key, value in settings_data.items():
|
||||||
|
_validate_setting_value(key, value)
|
||||||
|
|
||||||
# Check if setting exists
|
# Check if setting exists
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SystemSetting).where(SystemSetting.setting_key == key)
|
select(SystemSetting).where(SystemSetting.setting_key == key)
|
||||||
@@ -88,35 +168,45 @@ async def upload_company_logo(
|
|||||||
"""Upload company logo.
|
"""Upload company logo.
|
||||||
|
|
||||||
Saves logo to uploads/logos/ and updates company_logo_path setting.
|
Saves logo to uploads/logos/ and updates company_logo_path setting.
|
||||||
Only admins can upload the logo.
|
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.
|
||||||
"""
|
"""
|
||||||
# Validate file type (must be image)
|
# Read in chunks, stopping as soon as the size limit is crossed instead
|
||||||
allowed_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
# of buffering an oversized body into memory first.
|
||||||
if file.content_type not in allowed_types:
|
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||||
raise HTTPException(
|
buffer = bytearray()
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
chunk_size = 1024 * 1024
|
||||||
detail=f"File type {file.content_type} not allowed. Must be an image.",
|
while True:
|
||||||
)
|
chunk = await file.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
# Read file content
|
break
|
||||||
content = await file.read()
|
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)
|
file_size = len(content)
|
||||||
|
|
||||||
# Validate file size
|
# Validate file type from the actual bytes (SVG never matches: refused)
|
||||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
detected = detect_file_type(content)
|
||||||
if file_size > max_bytes:
|
if detected is None or detected[0] not in ALLOWED_IMAGE_TYPES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"File size {file_size} bytes exceeds maximum {settings.max_upload_size_mb}MB",
|
detail="File type not allowed. Must be a recognised image (SVG is not accepted).",
|
||||||
)
|
)
|
||||||
|
_detected_mime, detected_ext = detected
|
||||||
|
|
||||||
# Create logos directory
|
# Create logos directory
|
||||||
logos_dir = settings.upload_path / "logos"
|
logos_dir = settings.upload_path / "logos"
|
||||||
logos_dir.mkdir(parents=True, exist_ok=True)
|
logos_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Use a fixed filename for the company logo
|
# Fixed filename for the company logo, extension forced to what was
|
||||||
file_extension = Path(file.filename).suffix
|
# actually detected (not what the client claimed or the filename said).
|
||||||
logo_filename = f"company_logo{file_extension}"
|
logo_filename = f"company_logo{detected_ext}"
|
||||||
logo_path = logos_dir / logo_filename
|
logo_path = logos_dir / logo_filename
|
||||||
|
|
||||||
# Write file
|
# Write file
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -22,15 +25,29 @@ from src.backend.models.orm import (
|
|||||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||||
)
|
)
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from src.backend.services.auth_service import hash_password
|
from src.backend.services.auth_service import hash_password, issue_api_key, revoke_all_keys
|
||||||
from src.backend.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.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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -87,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,
|
||||||
@@ -220,14 +281,24 @@ async def setup_page(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
@@ -238,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"}
|
||||||
|
|
||||||
|
|
||||||
@@ -248,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(
|
||||||
@@ -259,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",
|
||||||
@@ -274,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():
|
||||||
@@ -330,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)
|
||||||
@@ -484,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
|
||||||
@@ -532,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
|
||||||
],
|
],
|
||||||
@@ -544,6 +616,7 @@ 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()
|
||||||
|
|
||||||
invalid = set(body.roles) - VALID_ROLES
|
invalid = set(body.roles) - VALID_ROLES
|
||||||
if invalid:
|
if invalid:
|
||||||
@@ -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"}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from src.backend.models.api.station import (
|
|||||||
StationResponse,
|
StationResponse,
|
||||||
StationRecipeAssignmentCreate,
|
StationRecipeAssignmentCreate,
|
||||||
StationRecipeAssignmentResponse,
|
StationRecipeAssignmentResponse,
|
||||||
|
StationResetResponse,
|
||||||
|
StationWithRecipesResponse,
|
||||||
RecipeSummary,
|
RecipeSummary,
|
||||||
)
|
)
|
||||||
from src.backend.services import station_service
|
from src.backend.services import station_service
|
||||||
@@ -18,15 +20,36 @@ 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,15 +1,18 @@
|
|||||||
"""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 src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.api.middleware.api_key import require_metrologist
|
from src.backend.api.middleware.api_key import require_metrologist
|
||||||
from src.backend.models.orm.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.statistics import (
|
from src.backend.models.api.statistics import (
|
||||||
CapabilityData,
|
CapabilityData,
|
||||||
@@ -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]
|
||||||
@@ -17,8 +17,13 @@ from src.backend.models.api.task import (
|
|||||||
TaskReorderRequest,
|
TaskReorderRequest,
|
||||||
TaskResponse,
|
TaskResponse,
|
||||||
TaskUpdate,
|
TaskUpdate,
|
||||||
|
check_task_loop_options,
|
||||||
|
default_in_loop,
|
||||||
|
normalize_note_fields,
|
||||||
|
validate_tolerance_order,
|
||||||
)
|
)
|
||||||
from src.backend.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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -261,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)
|
||||||
|
|
||||||
@@ -272,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)
|
||||||
|
|
||||||
@@ -283,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)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -309,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:
|
||||||
@@ -328,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,
|
||||||
@@ -339,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()
|
||||||
@@ -350,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)
|
||||||
|
|
||||||
@@ -367,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)
|
||||||
|
|
||||||
@@ -378,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)
|
||||||
|
|
||||||
@@ -391,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,13 +1,19 @@
|
|||||||
"""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 src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.api.middleware.api_key import require_admin_user
|
from src.backend.api.middleware.api_key import require_admin_user
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
from src.backend.models.api.user import PinSet, UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||||
from src.backend.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"])
|
||||||
|
|
||||||
@@ -78,6 +84,21 @@ 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)
|
||||||
|
|
||||||
|
# roles and is_admin are NOT NULL columns: an explicit null passed the
|
||||||
|
# Optional[...] schema check but blew up at flush time as an unhandled
|
||||||
|
# 500 (#36 point 4).
|
||||||
|
if "roles" in update_data and update_data["roles"] is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
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
|
# Validate roles if provided
|
||||||
if "roles" in update_data:
|
if "roles" in update_data:
|
||||||
for role in update_data["roles"]:
|
for role in update_data["roles"]:
|
||||||
@@ -86,6 +107,27 @@ async def update_user(
|
|||||||
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}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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()
|
||||||
@@ -100,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,
|
||||||
@@ -127,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()
|
||||||
|
|
||||||
|
|
||||||
@@ -142,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))
|
||||||
+62
-2
@@ -1,23 +1,54 @@
|
|||||||
"""TieMeasureFlow Server Configuration."""
|
"""TieMeasureFlow Server Configuration."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings
|
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):
|
class Settings(BaseSettings):
|
||||||
"""Application settings loaded from environment variables."""
|
"""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
|
# Database
|
||||||
db_host: str = "localhost"
|
db_host: str = "localhost"
|
||||||
db_port: int = 3306
|
db_port: int = 3306
|
||||||
db_name: str = "tiemeasureflow"
|
db_name: str = "tiemeasureflow"
|
||||||
db_user: str = "tmflow"
|
db_user: str = "tmflow"
|
||||||
db_password: str = "change_me_in_production"
|
db_password: str = _DEFAULT_DB_PASSWORD
|
||||||
|
|
||||||
|
# Presentation time zone (DB is UTC, #42)
|
||||||
|
display_timezone: str = "Europe/Rome"
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
server_host: str = "0.0.0.0"
|
server_host: str = "0.0.0.0"
|
||||||
server_port: int = 8000
|
server_port: int = 8000
|
||||||
server_secret_key: str = "change-this-to-a-random-secret-key"
|
server_secret_key: str = _DEFAULT_SECRET_KEY
|
||||||
server_cors_origins: str = "http://localhost:5000"
|
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
|
# File Storage
|
||||||
upload_dir: str = "uploads"
|
upload_dir: str = "uploads"
|
||||||
@@ -33,11 +64,18 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Setup page (empty = disabled)
|
# Setup page (empty = disabled)
|
||||||
setup_password: str | None = None
|
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)
|
# AI / OpenRouter (for technical sheet parsing)
|
||||||
openrouter_api_key: str | None = None
|
openrouter_api_key: str | None = None
|
||||||
openrouter_model: str = "anthropic/claude-sonnet-4"
|
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
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Async MySQL connection string."""
|
"""Async MySQL connection string."""
|
||||||
@@ -69,5 +107,27 @@ class Settings(BaseSettings):
|
|||||||
"extra": "ignore",
|
"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()
|
settings = Settings()
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ from src.backend.api.routers.reports import router as reports_router
|
|||||||
from src.backend.api.routers.statistics import router as statistics_router
|
from src.backend.api.routers.statistics import router as statistics_router
|
||||||
from src.backend.api.routers.setup import router as setup_router
|
from src.backend.api.routers.setup import router as setup_router
|
||||||
from src.backend.api.routers.stations import router as stations_router
|
from src.backend.api.routers.stations import router as stations_router
|
||||||
|
from src.backend.api.routers.production import router as production_router
|
||||||
|
from src.backend.api.routers.vision import router as vision_router
|
||||||
|
from src.backend.api.routers.task_results import router as task_results_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -73,6 +76,9 @@ app.include_router(statistics_router)
|
|||||||
app.include_router(reports_router)
|
app.include_router(reports_router)
|
||||||
app.include_router(setup_router)
|
app.include_router(setup_router)
|
||||||
app.include_router(stations_router)
|
app.include_router(stations_router)
|
||||||
|
app.include_router(production_router)
|
||||||
|
app.include_router(vision_router)
|
||||||
|
app.include_router(task_results_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ from src.backend.models.orm.task import RecipeTask, RecipeSubtask # noqa: F401
|
|||||||
from src.backend.models.orm.measurement import Measurement # noqa: F401
|
from src.backend.models.orm.measurement import Measurement # noqa: F401
|
||||||
from src.backend.models.orm.access_log import AccessLog # noqa: F401
|
from src.backend.models.orm.access_log import AccessLog # noqa: F401
|
||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit # noqa: F401
|
||||||
|
from src.backend.models.orm.station import Station, StationRecipeAssignment # noqa: F401
|
||||||
|
from src.backend.models.orm.production import ProductionRun, ProductionEvent # noqa: F401
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""base schema: state of the database immediately before migration 001
|
||||||
|
|
||||||
|
Every database that has run this project since before Alembic was wired in
|
||||||
|
was born from `Base.metadata.create_all()` (via `/api/setup/init-db`, or
|
||||||
|
`init_db()` in dev) - never from a migration chain, because there wasn't one.
|
||||||
|
`upgrade head` on a genuinely empty database therefore had no starting point:
|
||||||
|
001 is the oldest migration, and it only knows how to ADD columns to tables
|
||||||
|
that 001-013 all assume already exist.
|
||||||
|
|
||||||
|
This migration supplies that starting point, but only when there is nothing
|
||||||
|
there yet: on a database that already has `recipes` (the table 001 was
|
||||||
|
written against), it is a no-op, because that database was already born from
|
||||||
|
`create_all()` and already has this exact schema.
|
||||||
|
|
||||||
|
Written explicitly with `create_table` rather than `Base.metadata.create_all`:
|
||||||
|
`create_all` builds the CURRENT model definitions, which already carry every
|
||||||
|
column 001-013 add, and 001 would then try to add a column that is already
|
||||||
|
there (`Duplicate column`).
|
||||||
|
|
||||||
|
Revision ID: 000_base
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-09-21
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect as sa_inspect
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '000_base'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
# Table creation order matters: each one only references tables created
|
||||||
|
# before it (users -> recipes -> recipe_versions -> recipe_tasks ->
|
||||||
|
# recipe_subtasks -> measurements -> access_logs / system_settings /
|
||||||
|
# recipe_version_audit). Downgrade drops them in the opposite order.
|
||||||
|
_TABLES_NEWEST_FIRST = (
|
||||||
|
'recipe_version_audit', 'system_settings', 'access_logs', 'measurements',
|
||||||
|
'recipe_subtasks', 'recipe_tasks', 'recipe_versions', 'recipes', 'users',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
if sa_inspect(bind).has_table('recipes'):
|
||||||
|
# Already created (by create_all, or by a previous run of this same
|
||||||
|
# migration) - nothing to do.
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'users',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('username', sa.String(100), nullable=False),
|
||||||
|
sa.Column('password_hash', sa.String(255), nullable=False),
|
||||||
|
sa.Column('email', sa.String(255), nullable=True),
|
||||||
|
sa.Column('display_name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('roles', sa.JSON, nullable=False),
|
||||||
|
sa.Column('is_admin', sa.Boolean, nullable=False, server_default='0'),
|
||||||
|
sa.Column(
|
||||||
|
'language_pref', sa.Enum('it', 'en', name='language_enum'),
|
||||||
|
nullable=False, server_default='it',
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'theme_pref', sa.Enum('light', 'dark', name='theme_enum'),
|
||||||
|
nullable=False, server_default='light',
|
||||||
|
),
|
||||||
|
sa.Column('active', sa.Boolean, nullable=False, server_default='1'),
|
||||||
|
sa.Column('api_key', sa.String(64), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('last_login', sa.DateTime, nullable=True),
|
||||||
|
sa.UniqueConstraint('username', name='uq_users_username'),
|
||||||
|
sa.UniqueConstraint('api_key', name='uq_users_api_key'),
|
||||||
|
sa.Index('ix_users_username', 'username'),
|
||||||
|
sa.Index('ix_users_api_key', 'api_key'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'recipes',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('code', sa.String(100), nullable=False),
|
||||||
|
sa.Column('name', sa.String(255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text, nullable=True),
|
||||||
|
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('active', sa.Boolean, nullable=False, server_default='1'),
|
||||||
|
sa.UniqueConstraint('code', name='uq_recipes_code'),
|
||||||
|
sa.Index('ix_recipes_code', 'code'),
|
||||||
|
sa.Index('ix_recipes_active', 'active'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'recipe_versions',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
|
||||||
|
sa.Column('version_number', sa.Integer, nullable=False),
|
||||||
|
sa.Column('is_current', sa.Boolean, nullable=False, server_default='0'),
|
||||||
|
sa.Column('created_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('change_notes', sa.Text, nullable=True),
|
||||||
|
sa.UniqueConstraint('recipe_id', 'version_number', name='uq_recipe_version'),
|
||||||
|
sa.Index('ix_recipe_version_current', 'recipe_id', 'is_current'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'recipe_tasks',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||||
|
sa.Column('order_index', sa.Integer, nullable=False, server_default='0'),
|
||||||
|
sa.Column('title', sa.String(255), nullable=False),
|
||||||
|
sa.Column('directive', sa.Text, nullable=True),
|
||||||
|
sa.Column('description', sa.Text, nullable=True),
|
||||||
|
sa.Column('file_path', sa.String(500), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
'file_type', sa.Enum('image', 'pdf', name='file_type_enum'),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column('annotations_json', sa.JSON, nullable=True),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'recipe_subtasks',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
'task_id', sa.Integer,
|
||||||
|
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column('marker_number', sa.Integer, nullable=False),
|
||||||
|
sa.Column('description', sa.String(500), nullable=False),
|
||||||
|
sa.Column('measurement_type', sa.String(100), nullable=True),
|
||||||
|
sa.Column('nominal', sa.DECIMAL(12, 6), nullable=True),
|
||||||
|
sa.Column('utl', sa.DECIMAL(12, 6), nullable=True),
|
||||||
|
sa.Column('uwl', sa.DECIMAL(12, 6), nullable=True),
|
||||||
|
sa.Column('lwl', sa.DECIMAL(12, 6), nullable=True),
|
||||||
|
sa.Column('ltl', sa.DECIMAL(12, 6), nullable=True),
|
||||||
|
sa.Column('unit', sa.String(20), nullable=False, server_default='mm'),
|
||||||
|
sa.UniqueConstraint('task_id', 'marker_number', name='uq_task_marker'),
|
||||||
|
sa.Index('ix_subtask_task_id', 'task_id'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'measurements',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
'subtask_id', sa.Integer, sa.ForeignKey('recipe_subtasks.id'),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'measured_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column('value', sa.DECIMAL(12, 6), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
'pass_fail', sa.Enum('pass', 'warning', 'fail', name='pass_fail_enum'),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column('deviation', sa.DECIMAL(12, 6), nullable=True),
|
||||||
|
sa.Column('lot_number', sa.String(100), nullable=True),
|
||||||
|
sa.Column('serial_number', sa.String(100), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
'input_method',
|
||||||
|
sa.Enum('usb_caliper', 'manual', name='input_method_enum'),
|
||||||
|
nullable=False, server_default='manual',
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'measured_at', sa.DateTime, nullable=False, server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column('synced_to_csv', sa.Boolean, nullable=True, server_default='0'),
|
||||||
|
sa.Index('ix_measurements_subtask_id', 'subtask_id'),
|
||||||
|
sa.Index('ix_measurements_version_id', 'version_id'),
|
||||||
|
sa.Index('ix_measurements_measured_by', 'measured_by'),
|
||||||
|
sa.Index('ix_measurements_lot_number', 'lot_number'),
|
||||||
|
sa.Index('ix_measurements_serial_number', 'serial_number'),
|
||||||
|
sa.Index('ix_measurements_measured_at', 'measured_at'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'access_logs',
|
||||||
|
sa.Column('id', sa.BigInteger, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||||
|
sa.Column('action', sa.String(100), nullable=False),
|
||||||
|
sa.Column('details', sa.JSON, nullable=True),
|
||||||
|
sa.Column('ip_address', sa.String(45), nullable=True),
|
||||||
|
sa.Column('user_agent', sa.String(500), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Index('ix_access_logs_action', 'action'),
|
||||||
|
sa.Index('ix_access_log_user_time', 'user_id', 'created_at'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'system_settings',
|
||||||
|
sa.Column('setting_key', sa.String(100), primary_key=True),
|
||||||
|
sa.Column('setting_value', sa.Text, nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
'setting_type',
|
||||||
|
sa.Enum('string', 'number', 'boolean', 'json', name='setting_type_enum'),
|
||||||
|
nullable=False, server_default='string',
|
||||||
|
),
|
||||||
|
sa.Column('description', sa.String(500), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'recipe_version_audit',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
|
||||||
|
sa.Column('old_version_id', sa.Integer, nullable=True),
|
||||||
|
sa.Column('new_version_id', sa.Integer, nullable=False),
|
||||||
|
sa.Column('changed_by', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
'change_type',
|
||||||
|
sa.Enum('CREATE', 'UPDATE', 'ACTIVATE', 'RETIRE', name='change_type_enum'),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column('change_reason', sa.Text, nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Index('ix_recipe_audit_recipe_time', 'recipe_id', 'created_at'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa_inspect(bind)
|
||||||
|
for table in _TABLES_NEWEST_FIRST:
|
||||||
|
if inspector.has_table(table):
|
||||||
|
op.drop_table(table)
|
||||||
@@ -12,7 +12,7 @@ import sqlalchemy as sa
|
|||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '001_image_path'
|
revision: str = '001_image_path'
|
||||||
down_revision: Union[str, None] = None
|
down_revision: Union[str, None] = '000_base'
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""add production_runs and production_events
|
||||||
|
|
||||||
|
Gives a production a life of its own: before this its state lived in the Alpine
|
||||||
|
component of task_execute.html, so changing task - a full page load - lost the timer,
|
||||||
|
the cycle count and the "production started" flag.
|
||||||
|
|
||||||
|
Revision ID: 005_production_runs
|
||||||
|
Revises: 004_input_duration
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '005_production_runs'
|
||||||
|
down_revision: Union[str, None] = '004_input_duration'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'production_runs',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('station_id', sa.Integer, sa.ForeignKey('stations.id'), nullable=False),
|
||||||
|
sa.Column('recipe_id', sa.Integer, sa.ForeignKey('recipes.id'), nullable=False),
|
||||||
|
sa.Column('version_id', sa.Integer, sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||||
|
sa.Column('operator_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('lot_number', sa.String(100), nullable=True),
|
||||||
|
sa.Column('serial_number', sa.String(100), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
'status',
|
||||||
|
sa.Enum('running', 'paused', 'closed', name='production_run_status_enum'),
|
||||||
|
nullable=False,
|
||||||
|
server_default='running',
|
||||||
|
),
|
||||||
|
sa.Column('measurement_interval_minutes', sa.SmallInteger, nullable=True),
|
||||||
|
sa.Column('next_measurement_at', sa.DateTime, nullable=True),
|
||||||
|
sa.Column('cycle_count', sa.Integer, nullable=False, server_default='0'),
|
||||||
|
sa.Column('started_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('paused_at', sa.DateTime, nullable=True),
|
||||||
|
sa.Column('closed_at', sa.DateTime, nullable=True),
|
||||||
|
sa.Column('closed_by', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||||
|
# Mirrors station_id while open, NULL once closed. The unique constraint makes
|
||||||
|
# "one open run per station" a database guarantee rather than a race; repeated
|
||||||
|
# NULLs do not collide, so closed runs are free to pile up.
|
||||||
|
sa.Column('active_station_id', sa.Integer, nullable=True),
|
||||||
|
# Declared inline rather than added afterwards: adding a constraint is an ALTER,
|
||||||
|
# which SQLite cannot do, and the test databases are SQLite.
|
||||||
|
sa.UniqueConstraint('active_station_id', name='uq_production_runs_active_station'),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
op.create_index('ix_production_runs_station_id', 'production_runs', ['station_id'])
|
||||||
|
op.create_index('ix_production_runs_recipe_id', 'production_runs', ['recipe_id'])
|
||||||
|
op.create_index('ix_production_runs_version_id', 'production_runs', ['version_id'])
|
||||||
|
op.create_index('ix_production_runs_operator_id', 'production_runs', ['operator_id'])
|
||||||
|
op.create_index('ix_production_runs_lot_number', 'production_runs', ['lot_number'])
|
||||||
|
op.create_index('ix_production_runs_serial_number', 'production_runs', ['serial_number'])
|
||||||
|
op.create_index('ix_production_runs_status', 'production_runs', ['status'])
|
||||||
|
op.create_index(
|
||||||
|
'ix_production_runs_next_measurement_at', 'production_runs', ['next_measurement_at'],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
'ix_production_runs_station_status', 'production_runs', ['station_id', 'status'],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'production_events',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
'run_id', sa.Integer,
|
||||||
|
sa.ForeignKey('production_runs.id', ondelete='CASCADE'), nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
'event_type',
|
||||||
|
sa.Enum(
|
||||||
|
'start', 'cycle_completed', 'line_stop', 'resume', 'close',
|
||||||
|
name='production_event_type_enum',
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('supervisor_id', sa.Integer, sa.ForeignKey('users.id'), nullable=True),
|
||||||
|
sa.Column('note', sa.Text, nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
op.create_index('ix_production_events_run_id', 'production_events', ['run_id'])
|
||||||
|
op.create_index('ix_production_events_event_type', 'production_events', ['event_type'])
|
||||||
|
op.create_index('ix_production_events_user_id', 'production_events', ['user_id'])
|
||||||
|
op.create_index('ix_production_events_created_at', 'production_events', ['created_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('production_events')
|
||||||
|
op.drop_table('production_runs')
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""link measurements to their production run
|
||||||
|
|
||||||
|
Fine produzione has to hand the measurements of the whole production to the
|
||||||
|
statistics file. Without this column "the measurements of this production" is not a
|
||||||
|
query: measurements only knew their recipe version, lot and serial, none of which
|
||||||
|
delimits one run from the next on the same recipe and lot.
|
||||||
|
|
||||||
|
Nullable on purpose: measurements taken before this - and any taken outside a
|
||||||
|
production - simply have no run.
|
||||||
|
|
||||||
|
Revision ID: 006_measurement_run
|
||||||
|
Revises: 005_production_runs
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '006_measurement_run'
|
||||||
|
down_revision: Union[str, None] = '005_production_runs'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Batch mode, because the column carries a foreign key: adding a constraint is
|
||||||
|
# an ALTER that SQLite cannot do, and alembic raises rather than silently
|
||||||
|
# dropping it. On MySQL this is a plain ALTER; on SQLite it rebuilds the table.
|
||||||
|
# Keeping the key is worth the ceremony - ON DELETE SET NULL means a measurement,
|
||||||
|
# the record that matters in an audit, survives its run being deleted.
|
||||||
|
with op.batch_alter_table('measurements') as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
'production_run_id', sa.Integer,
|
||||||
|
sa.ForeignKey(
|
||||||
|
'production_runs.id',
|
||||||
|
ondelete='SET NULL',
|
||||||
|
# Named because batch mode requires it, and because an anonymous
|
||||||
|
# constraint cannot be referred to later.
|
||||||
|
name='fk_measurements_production_run',
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
'ix_measurements_production_run_id', 'measurements', ['production_run_id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Where the statistics file for a closed run was written. No constraint, so a
|
||||||
|
# plain ALTER is enough.
|
||||||
|
op.add_column(
|
||||||
|
'production_runs',
|
||||||
|
sa.Column('statistics_path', sa.String(500), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('production_runs', 'statistics_path')
|
||||||
|
op.drop_index('ix_measurements_production_run_id', table_name='measurements')
|
||||||
|
with op.batch_alter_table('measurements') as batch_op:
|
||||||
|
batch_op.drop_column('production_run_id')
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""add an explicit type to recipe tasks
|
||||||
|
|
||||||
|
Until now the kind of a task was deduced: quotes present meant a measurement,
|
||||||
|
otherwise a note. A measurement task whose quotes had not been entered yet was
|
||||||
|
therefore treated as a note - the system behaved differently depending on how
|
||||||
|
complete the recipe happened to be.
|
||||||
|
|
||||||
|
Existing rows are classified by the rule that was in force, so nothing changes
|
||||||
|
behaviour on upgrade: tasks with quotes become 'measure'. Tasks with no quotes but
|
||||||
|
a drawing attached become 'drawing' rather than 'note' - that is what they already
|
||||||
|
displayed as, and calling them notes would be the one place this migration did
|
||||||
|
change behaviour.
|
||||||
|
|
||||||
|
Revision ID: 007_task_type
|
||||||
|
Revises: 006_measurement_run
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '007_task_type'
|
||||||
|
down_revision: Union[str, None] = '006_measurement_run'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
TASK_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'recipe_tasks',
|
||||||
|
sa.Column(
|
||||||
|
'task_type',
|
||||||
|
sa.Enum(*TASK_TYPES, name='task_type_enum'),
|
||||||
|
nullable=False,
|
||||||
|
server_default='note',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index('ix_recipe_tasks_task_type', 'recipe_tasks', ['task_type'])
|
||||||
|
|
||||||
|
# Backfill by the rule the frontend used, so no recipe changes behaviour.
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE recipe_tasks
|
||||||
|
SET task_type = 'measure'
|
||||||
|
WHERE id IN (SELECT DISTINCT task_id FROM recipe_subtasks)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE recipe_tasks
|
||||||
|
SET task_type = 'drawing'
|
||||||
|
WHERE task_type = 'note'
|
||||||
|
AND file_path IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_recipe_tasks_task_type', table_name='recipe_tasks')
|
||||||
|
op.drop_column('recipe_tasks', 'task_type')
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""widen the production event trace for the measurement loop
|
||||||
|
|
||||||
|
The loop needs to distinguish two things the trace could not say before: a
|
||||||
|
measurement task finished while the cycle is still running (task_measured), and the
|
||||||
|
piece turned over and measured again without closing the cycle (remeasure). Only
|
||||||
|
the last measurement task of a recipe still produces cycle_completed, because only
|
||||||
|
that one restarts the interval.
|
||||||
|
|
||||||
|
Both values are added in one go. Widening a MySQL enum rewrites the table, so the
|
||||||
|
values that are already planned go in now rather than one migration at a time -
|
||||||
|
the same reasoning as the task types in 007.
|
||||||
|
|
||||||
|
The events that happen inside a cycle also need to say which task they are about,
|
||||||
|
so the column comes with them: a task_measured that does not name its task records
|
||||||
|
nothing usable.
|
||||||
|
|
||||||
|
Revision ID: 008_loop_events
|
||||||
|
Revises: 007_task_type
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '008_loop_events'
|
||||||
|
down_revision: Union[str, None] = '007_task_type'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_TYPES = ('start', 'cycle_completed', 'line_stop', 'resume', 'close')
|
||||||
|
NEW_TYPES = (
|
||||||
|
'start', 'cycle_completed', 'task_measured', 'remeasure',
|
||||||
|
'line_stop', 'resume', 'close',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _alter(values: Sequence[str]) -> None:
|
||||||
|
"""Restate the column with a new set of allowed values.
|
||||||
|
|
||||||
|
On MySQL this is the ALTER that rewrites the enum. On SQLite, where an enum is
|
||||||
|
stored as a plain VARCHAR, batch mode rebuilds the table instead of emitting an
|
||||||
|
ALTER the engine does not support - the tests run there.
|
||||||
|
"""
|
||||||
|
with op.batch_alter_table('production_events') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'event_type',
|
||||||
|
existing_type=sa.Enum(*OLD_TYPES, name='production_event_type_enum'),
|
||||||
|
type_=sa.Enum(*values, name='production_event_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_alter(NEW_TYPES)
|
||||||
|
with op.batch_alter_table('production_events') as batch:
|
||||||
|
batch.add_column(sa.Column('task_id', sa.Integer(), nullable=True))
|
||||||
|
batch.create_foreign_key(
|
||||||
|
'fk_production_events_task_id', 'recipe_tasks', ['task_id'], ['id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('production_events') as batch:
|
||||||
|
batch.drop_constraint('fk_production_events_task_id', type_='foreignkey')
|
||||||
|
batch.drop_column('task_id')
|
||||||
|
# The new kinds of event have no equivalent in the old vocabulary, and a row
|
||||||
|
# left with a value the column no longer accepts would fail the rebuild.
|
||||||
|
op.execute(
|
||||||
|
"DELETE FROM production_events "
|
||||||
|
"WHERE event_type IN ('task_measured', 'remeasure')"
|
||||||
|
)
|
||||||
|
_alter(OLD_TYPES)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""traceability and manual input become rules of the recipe
|
||||||
|
|
||||||
|
Three settings that were nowhere: whether the lot and the serial are compulsory,
|
||||||
|
and whether a value may be typed instead of read from the caliper. Until now lot
|
||||||
|
and serial were optional everywhere and the keypad was always available, so a
|
||||||
|
value that happens to be in tolerance could simply be entered by hand.
|
||||||
|
|
||||||
|
Backfill: the two traceability flags start false, which is exactly today's
|
||||||
|
behaviour. Manual input starts *true* on the recipes that already exist - the
|
||||||
|
column default is false, so recipes written from now on are caliper-only, but
|
||||||
|
flipping the ones already in use would stop a running line at the next
|
||||||
|
measurement. Turning them off is a decision for whoever owns the recipe, taken in
|
||||||
|
the editor, not a side effect of an upgrade.
|
||||||
|
|
||||||
|
Revision ID: 009_recipe_rules
|
||||||
|
Revises: 008_loop_events
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '009_recipe_rules'
|
||||||
|
down_revision: Union[str, None] = '008_loop_events'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'recipes',
|
||||||
|
sa.Column(
|
||||||
|
'requires_lot', sa.Boolean(), nullable=False, server_default='0',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'recipes',
|
||||||
|
sa.Column(
|
||||||
|
'requires_serial', sa.Boolean(), nullable=False, server_default='0',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'recipes',
|
||||||
|
sa.Column(
|
||||||
|
'allow_manual_input', sa.Boolean(), nullable=False, server_default='0',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Recipes already in production keep the behaviour they were written under.
|
||||||
|
op.execute("UPDATE recipes SET allow_manual_input = 1")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('recipes', 'allow_manual_input')
|
||||||
|
op.drop_column('recipes', 'requires_serial')
|
||||||
|
op.drop_column('recipes', 'requires_lot')
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""record who authorised an out-of-tolerance measurement
|
||||||
|
|
||||||
|
The supervisor gate existed on screen and nowhere else: the modal opened, the
|
||||||
|
credentials were checked, the modal closed, and nothing was written down. Nothing
|
||||||
|
depended on the answer either, so a value outside tolerance could be left behind
|
||||||
|
by dismissing the modal.
|
||||||
|
|
||||||
|
These two columns are what the rule now stands on. A failed measurement with no
|
||||||
|
supervisor on it is a measurement waiting for one, and while one is waiting the
|
||||||
|
operator cannot move to the next quote.
|
||||||
|
|
||||||
|
Existing rows are left null. Backfilling an authorisation that never happened
|
||||||
|
would be inventing an audit record; the fails already in the database belong to
|
||||||
|
production runs that are over, and nothing is waiting on them.
|
||||||
|
|
||||||
|
Revision ID: 010_meas_authorisation
|
||||||
|
Revises: 009_recipe_rules
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '010_meas_authorisation'
|
||||||
|
down_revision: Union[str, None] = '009_recipe_rules'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.add_column(sa.Column('supervisor_id', sa.Integer(), nullable=True))
|
||||||
|
batch.add_column(sa.Column('authorised_at', sa.DateTime(), nullable=True))
|
||||||
|
batch.create_foreign_key(
|
||||||
|
'fk_measurements_supervisor_id', 'users', ['supervisor_id'], ['id'],
|
||||||
|
)
|
||||||
|
batch.create_index(
|
||||||
|
'ix_measurements_supervisor_id', ['supervisor_id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.drop_index('ix_measurements_supervisor_id')
|
||||||
|
batch.drop_constraint('fk_measurements_supervisor_id', type_='foreignkey')
|
||||||
|
batch.drop_column('authorised_at')
|
||||||
|
batch.drop_column('supervisor_id')
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""the vision graph lives on the task, and the quote names its output
|
||||||
|
|
||||||
|
`xf_compare` was a typo for `dxf_compare`. No row uses either value yet, so
|
||||||
|
renaming costs one statement here; from the first saved task onwards it would
|
||||||
|
be a data migration and the typo would already be in the public API.
|
||||||
|
|
||||||
|
Revision ID: 011_vision_graph
|
||||||
|
Revises: 010_meas_authorisation
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '011_vision_graph'
|
||||||
|
down_revision: Union[str, None] = '010_meas_authorisation'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_TYPES = ('note', 'measure', 'drawing', 'xf_compare', 'camera_measure')
|
||||||
|
NEW_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.add_column(sa.Column('vision_json', sa.JSON(), nullable=True))
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('recipe_subtasks') as batch:
|
||||||
|
batch.add_column(
|
||||||
|
sa.Column('vision_output', sa.String(length=120), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_subtasks') as batch:
|
||||||
|
batch.drop_column('vision_output')
|
||||||
|
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
batch.drop_column('vision_json')
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""one row per vision execution, and camera as an input method
|
||||||
|
|
||||||
|
The vision surroundings do not go on `measurements`: statistics and the export
|
||||||
|
read that table on every pass. And it would be wrong modelling anyway - one
|
||||||
|
acquisition produces N quotes, and image, overlay, device and engine version are
|
||||||
|
the same for all of them.
|
||||||
|
|
||||||
|
Revision ID: 012_vision_results
|
||||||
|
Revises: 011_vision_graph
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '012_vision_results'
|
||||||
|
down_revision: Union[str, None] = '011_vision_graph'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_METHODS = ('usb_caliper', 'manual')
|
||||||
|
NEW_METHODS = ('usb_caliper', 'manual', 'camera')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'vision_results',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
||||||
|
sa.Column('image_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('overlay_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('engine_version', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('executed_on',
|
||||||
|
sa.Enum('server', 'station', name='vision_executed_on_enum'),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column('station_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('stations.id'), nullable=True),
|
||||||
|
sa.Column('device_code', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('graph_snapshot', sa.JSON(), nullable=False),
|
||||||
|
sa.Column('duration_ms', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('executed_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'input_method',
|
||||||
|
existing_type=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||||
|
type_=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
batch.add_column(
|
||||||
|
sa.Column('vision_result_id', sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
batch.create_foreign_key(
|
||||||
|
'fk_measurements_vision_result',
|
||||||
|
'vision_results', ['vision_result_id'], ['id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('measurements') as batch:
|
||||||
|
batch.drop_constraint('fk_measurements_vision_result',
|
||||||
|
type_='foreignkey')
|
||||||
|
batch.drop_column('vision_result_id')
|
||||||
|
batch.alter_column(
|
||||||
|
'input_method',
|
||||||
|
existing_type=sa.Enum(*NEW_METHODS, name='input_method_enum'),
|
||||||
|
type_=sa.Enum(*OLD_METHODS, name='input_method_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_table('vision_results')
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""reference images, with their provenance
|
||||||
|
|
||||||
|
A reference image without provenance is a trap: the graph is re-run months later,
|
||||||
|
different numbers come out, and there is no way to tell an updated engine from a
|
||||||
|
moved lens.
|
||||||
|
|
||||||
|
`expected_json` holds what the graph is supposed to produce on this image. It is
|
||||||
|
what makes a regression run possible after a VisionSuite upgrade - the safety net
|
||||||
|
that makes bumping the submodule sustainable with an engine on two hosts.
|
||||||
|
|
||||||
|
Revision ID: 013_reference_images
|
||||||
|
Revises: 012_vision_results
|
||||||
|
Create Date: 2026-08-16
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '013_reference_images'
|
||||||
|
down_revision: Union[str, None] = '012_vision_results'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'vision_reference_images',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id'), nullable=False, index=True),
|
||||||
|
sa.Column('path', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('station_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('stations.id'), nullable=True),
|
||||||
|
sa.Column('device_code', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('calibration_snapshot', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('engine_version', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('note', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('expected_json', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('acquired_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('vision_reference_images')
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""vision results and reference images cascade with their task
|
||||||
|
|
||||||
|
Both `vision_results.task_id` and `vision_reference_images.task_id` were
|
||||||
|
created (012, 013) with a plain, unnamed foreign key - no ON DELETE clause,
|
||||||
|
so deleting a task with any camera history or reference image attached fails
|
||||||
|
on a strict FK check (MySQL) instead of taking the vision rows with it, the
|
||||||
|
way `production_events.run_id` already does for a closed production run.
|
||||||
|
|
||||||
|
The original constraint has no name to drop by (SQLAlchemy leaves inline
|
||||||
|
`ForeignKey(...)` unnamed unless told otherwise, and MySQL then assigns its
|
||||||
|
own `<table>_ibfk_N`, which is not portable to reflect on). `sa.inspect`
|
||||||
|
looks it up at migration time instead of guessing it - the same way on
|
||||||
|
MySQL and on SQLite (batch mode), where an anonymous FK reflects with
|
||||||
|
`name=None` and there is nothing to drop before adding the new one.
|
||||||
|
|
||||||
|
Reference images on disk are not touched here - deleting a task's row does
|
||||||
|
not, on its own, know to also delete the file next to it; that gap is
|
||||||
|
declared open in issue #38 point 6's write-up (`vision_service` has no
|
||||||
|
single point today that intercepts a task deletion).
|
||||||
|
|
||||||
|
Revision ID: 014_vision_fk_ondelete
|
||||||
|
Revises: 013_reference_images
|
||||||
|
Create Date: 2026-09-21
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '014_vision_fk_ondelete'
|
||||||
|
down_revision: Union[str, None] = '013_reference_images'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_NEW_FK_NAMES = {
|
||||||
|
'vision_results': 'fk_vision_results_task_id',
|
||||||
|
'vision_reference_images': 'fk_vision_reference_images_task_id',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_task_id_fk_name(bind, table_name: str) -> Union[str, None]:
|
||||||
|
"""The name reflection gives the pre-existing `task_id` FK, if any.
|
||||||
|
|
||||||
|
None on SQLite for a constraint that was never named - batch mode then
|
||||||
|
has nothing to drop, and just adds the new named one alongside it.
|
||||||
|
"""
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
for fk in inspector.get_foreign_keys(table_name):
|
||||||
|
if fk.get("constrained_columns") == ["task_id"]:
|
||||||
|
return fk.get("name")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
for table_name, new_name in _NEW_FK_NAMES.items():
|
||||||
|
existing_name = _existing_task_id_fk_name(bind, table_name)
|
||||||
|
with op.batch_alter_table(table_name) as batch:
|
||||||
|
if existing_name:
|
||||||
|
batch.drop_constraint(existing_name, type_='foreignkey')
|
||||||
|
batch.create_foreign_key(
|
||||||
|
new_name, 'recipe_tasks', ['task_id'], ['id'],
|
||||||
|
ondelete='CASCADE',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Reverse order: reference_images before results, mirroring upgrade().
|
||||||
|
for table_name, name in reversed(list(_NEW_FK_NAMES.items())):
|
||||||
|
with op.batch_alter_table(table_name) as batch:
|
||||||
|
batch.drop_constraint(name, type_='foreignkey')
|
||||||
|
batch.create_foreign_key(
|
||||||
|
name, 'recipe_tasks', ['task_id'], ['id'],
|
||||||
|
)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""API keys as SHA-256 hashes, one per session (#41)
|
||||||
|
|
||||||
|
Replaces the plaintext `users.api_key` column with an `api_keys` table: one
|
||||||
|
row per login, holding only the SHA-256 hex digest, an expiry and the last
|
||||||
|
use. Existing keys are invalidated on purpose, not migrated - the plaintext
|
||||||
|
is exactly what must stop existing, so everybody logs in once again.
|
||||||
|
|
||||||
|
Revision ID: 015_api_key_hash
|
||||||
|
Revises: 014_vision_fk_ondelete
|
||||||
|
Create Date: 2026-10-06
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '015_api_key_hash'
|
||||||
|
down_revision: Union[str, None] = '014_vision_fk_ondelete'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'api_keys',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||||
|
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||||
|
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
batch.drop_index('ix_users_api_key')
|
||||||
|
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||||
|
batch.drop_column('api_key')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
batch.add_column(sa.Column('api_key', sa.String(64), nullable=True))
|
||||||
|
batch.create_unique_constraint('uq_users_api_key', ['api_key'])
|
||||||
|
batch.create_index('ix_users_api_key', ['api_key'])
|
||||||
|
|
||||||
|
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
|
||||||
|
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
|
||||||
|
op.drop_table('api_keys')
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
|
||||||
|
|
||||||
|
Percorso relativo sotto upload_path della cartella immutabile che contiene
|
||||||
|
recipe.json e i file di meta (DXF, modelli .npz, ...).
|
||||||
|
|
||||||
|
Revision ID: 016_vision_recipe_dir
|
||||||
|
Revises: 015_api_key_hash
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '016_vision_recipe_dir'
|
||||||
|
down_revision: Union[str, None] = '015_api_key_hash'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.drop_column('vision_recipe_dir')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
|
||||||
|
|
||||||
|
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
|
||||||
|
|
||||||
|
Revision ID: 017_user_pin
|
||||||
|
Revises: 016_vision_recipe_dir
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '017_user_pin'
|
||||||
|
down_revision: Union[str, None] = '016_vision_recipe_dir'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
|
||||||
|
batch.add_column(sa.Column(
|
||||||
|
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
|
||||||
|
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
batch.drop_column('pin_locked_until')
|
||||||
|
batch.drop_column('pin_failed_count')
|
||||||
|
batch.drop_column('pin_hash')
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""task: in_loop / at_close / fields, tipo 'check', tabella task_results (#53 #54 #55)
|
||||||
|
|
||||||
|
in_loop: il task fa parte del ciclo di produzione (prima: implicito dal tipo).
|
||||||
|
at_close: il task 'note' si compila alla fine produzione.
|
||||||
|
fields: campi extra del task 'note' (JSON).
|
||||||
|
task_results: esito registrato da un task 'check' (OK/NOK) o 'note' (done).
|
||||||
|
|
||||||
|
Revision ID: 018_task_loop_results
|
||||||
|
Revises: 017_user_pin
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '018_task_loop_results'
|
||||||
|
down_revision: Union[str, None] = '017_user_pin'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
OLD_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||||
|
NEW_TYPES = OLD_TYPES + ('check',)
|
||||||
|
# stessi tipi di MEASURING_TASK_TYPES: fino a oggi erano l'unico ciclo possibile
|
||||||
|
LOOP_TYPES = ('measure', 'camera_measure', 'dxf_compare')
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.add_column(sa.Column(
|
||||||
|
'in_loop', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
batch.add_column(sa.Column(
|
||||||
|
'at_close', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||||
|
batch.add_column(sa.Column('fields', sa.JSON(), nullable=True))
|
||||||
|
# enum allargato come in 011: 'check' = Controllo OK/NOK
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
|
||||||
|
# i task di misura erano gia' nel ciclo: lo restano
|
||||||
|
recipe_tasks = sa.table(
|
||||||
|
'recipe_tasks', sa.column('task_type', sa.String), sa.column('in_loop', sa.Boolean))
|
||||||
|
op.execute(
|
||||||
|
recipe_tasks.update()
|
||||||
|
.where(recipe_tasks.c.task_type.in_(LOOP_TYPES))
|
||||||
|
.values(in_loop=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'task_results',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('task_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('version_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||||
|
sa.Column('production_run_id', sa.Integer(),
|
||||||
|
sa.ForeignKey('production_runs.id', ondelete='SET NULL'), nullable=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('outcome', sa.Enum('ok', 'nok', 'done', name='task_outcome_enum'),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column('note', sa.Text(), nullable=True),
|
||||||
|
sa.Column('values', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('attachments', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False,
|
||||||
|
server_default=sa.func.now()),
|
||||||
|
mysql_engine='InnoDB',
|
||||||
|
mysql_charset='utf8mb4',
|
||||||
|
)
|
||||||
|
op.create_index('ix_task_results_task_id', 'task_results', ['task_id'])
|
||||||
|
op.create_index('ix_task_results_version_id', 'task_results', ['version_id'])
|
||||||
|
op.create_index(
|
||||||
|
'ix_task_results_production_run_id', 'task_results', ['production_run_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# il tipo 'check' non esiste prima di 018: se un task lo usa, tornare indietro
|
||||||
|
# lo lascerebbe con un valore fuori enum
|
||||||
|
bind = op.get_bind()
|
||||||
|
used = bind.execute(
|
||||||
|
sa.text("SELECT COUNT(*) FROM recipe_tasks WHERE task_type = 'check'")
|
||||||
|
).scalar()
|
||||||
|
if used:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Downgrade 018 impossibile: {used} task usano il tipo 'check'. "
|
||||||
|
"Cambiare il tipo o eliminare quei task prima di tornare indietro."
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_index('ix_task_results_production_run_id', table_name='task_results')
|
||||||
|
op.drop_index('ix_task_results_version_id', table_name='task_results')
|
||||||
|
op.drop_index('ix_task_results_task_id', table_name='task_results')
|
||||||
|
op.drop_table('task_results')
|
||||||
|
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.alter_column(
|
||||||
|
'task_type',
|
||||||
|
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||||
|
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default='note',
|
||||||
|
)
|
||||||
|
batch.drop_column('fields')
|
||||||
|
batch.drop_column('at_close')
|
||||||
|
batch.drop_column('in_loop')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""stations.vision_worker_url e vision_results.worker_url (#45)
|
||||||
|
|
||||||
|
stations.vision_worker_url: worker di visione della stazione (NULL = centrale).
|
||||||
|
vision_results.worker_url: worker effettivamente chiamato per quel risultato.
|
||||||
|
|
||||||
|
Revision ID: 019_station_vision_worker
|
||||||
|
Revises: 018_task_loop_results
|
||||||
|
Create Date: 2026-10-11
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '019_station_vision_worker'
|
||||||
|
down_revision: Union[str, None] = '018_task_loop_results'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.add_column(sa.Column('vision_worker_url', sa.String(500), nullable=True))
|
||||||
|
with op.batch_alter_table('vision_results') as batch:
|
||||||
|
batch.add_column(sa.Column('worker_url', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('vision_results') as batch:
|
||||||
|
batch.drop_column('worker_url')
|
||||||
|
with op.batch_alter_table('stations') as batch:
|
||||||
|
batch.drop_column('vision_worker_url')
|
||||||
@@ -1,19 +1,33 @@
|
|||||||
"""Pydantic schemas for Measurement operations."""
|
"""Pydantic schemas for Measurement operations."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class MeasurementCreate(BaseModel):
|
class MeasurementCreate(BaseModel):
|
||||||
"""Schema for creating a single measurement."""
|
"""Schema for creating a single measurement."""
|
||||||
subtask_id: int
|
subtask_id: int
|
||||||
version_id: int
|
version_id: int
|
||||||
|
# Finiteness and the DECIMAL(12,6) scale bound are enforced in
|
||||||
|
# measurement_service.save_measurement, not with a Field constraint here:
|
||||||
|
# a NaN/Infinity that fails a pydantic Field constraint gets echoed back
|
||||||
|
# in the validation error's "input", and FastAPI's default error response
|
||||||
|
# then crashes with a 500 trying to JSON-encode that same NaN/Infinity
|
||||||
|
# (json.dumps(allow_nan=False) in Starlette's JSONResponse). A plain
|
||||||
|
# HTTPException raised after parsing sidesteps that.
|
||||||
value: float
|
value: float
|
||||||
lot_number: Optional[str] = Field(None, max_length=100)
|
lot_number: Optional[str] = Field(None, max_length=100)
|
||||||
serial_number: Optional[str] = Field(None, max_length=100)
|
serial_number: Optional[str] = Field(None, max_length=100)
|
||||||
|
# "camera" is deliberately absent from this pattern, even though the ORM
|
||||||
|
# enum and the database allow it. Do not add it here: a camera
|
||||||
|
# measurement must be produced by vision_service.execute_task, never by a
|
||||||
|
# client posting JSON straight to this endpoint - there is no image, no
|
||||||
|
# graph and no engine_version behind a value that arrived this way.
|
||||||
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
input_method: str = Field("manual", pattern="^(usb_caliper|manual)$")
|
||||||
input_duration_ms: Optional[int] = Field(None, ge=0)
|
input_duration_ms: Optional[int] = Field(None, ge=0)
|
||||||
|
# The production this belongs to, when one is open at the station.
|
||||||
|
production_run_id: Optional[int] = Field(None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
class MeasurementBatchCreate(BaseModel):
|
class MeasurementBatchCreate(BaseModel):
|
||||||
@@ -36,7 +50,12 @@ class MeasurementResponse(BaseModel):
|
|||||||
serial_number: Optional[str] = None
|
serial_number: Optional[str] = None
|
||||||
input_method: str
|
input_method: str
|
||||||
input_duration_ms: Optional[int] = None
|
input_duration_ms: Optional[int] = None
|
||||||
measured_at: datetime
|
production_run_id: Optional[int] = None
|
||||||
|
# Who let this value stand, when it was out of tolerance. Null on a value in
|
||||||
|
# tolerance - and on one that is not and is still waiting for an answer.
|
||||||
|
supervisor_id: Optional[int] = None
|
||||||
|
authorised_at: Optional[UtcDateTime] = None
|
||||||
|
measured_at: UtcDateTime
|
||||||
synced_to_csv: bool
|
synced_to_csv: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -49,6 +68,57 @@ class MeasurementListResponse(BaseModel):
|
|||||||
pages: int
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class MeasurementAuthorisation(BaseModel):
|
||||||
|
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
||||||
|
|
||||||
|
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||||
|
supervisor_username: Optional[str] = Field(None, min_length=1)
|
||||||
|
supervisor_password: Optional[str] = Field(None, min_length=1)
|
||||||
|
supervisor_user_id: Optional[int] = Field(None, gt=0)
|
||||||
|
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _one_credential_pair(self):
|
||||||
|
by_password = self.supervisor_username and self.supervisor_password
|
||||||
|
by_pin = self.supervisor_user_id and self.supervisor_pin
|
||||||
|
if not (by_password or by_pin):
|
||||||
|
raise ValueError(
|
||||||
|
"supervisor credentials required: username+password or user_id+pin"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TaskProgressResponse(BaseModel):
|
||||||
|
"""How far one measurement task has got: quotes taken out of quotes expected."""
|
||||||
|
|
||||||
|
task_id: int
|
||||||
|
quotes: int
|
||||||
|
measured: int
|
||||||
|
# empty (declared as measurement, no quotes yet) | none | partial | complete
|
||||||
|
state: str
|
||||||
|
|
||||||
|
|
||||||
|
class TaskProgressListResponse(BaseModel):
|
||||||
|
"""Progress of every measurement task of a version."""
|
||||||
|
|
||||||
|
tasks: list[TaskProgressResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class CycleMeasurementResponse(BaseModel):
|
||||||
|
"""One quote already measured in the cycle under way."""
|
||||||
|
|
||||||
|
subtask_id: int
|
||||||
|
value: float
|
||||||
|
pass_fail: str
|
||||||
|
measured_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentCycleResponse(BaseModel):
|
||||||
|
"""The quotes to restore on the screen after a reload; empty if the task is closed."""
|
||||||
|
|
||||||
|
measurements: list[CycleMeasurementResponse]
|
||||||
|
|
||||||
|
|
||||||
class MeasurementQuery(BaseModel):
|
class MeasurementQuery(BaseModel):
|
||||||
"""Schema for measurement query filters."""
|
"""Schema for measurement query filters."""
|
||||||
recipe_id: Optional[int] = None
|
recipe_id: Optional[int] = None
|
||||||
@@ -57,8 +127,8 @@ class MeasurementQuery(BaseModel):
|
|||||||
measured_by: Optional[int] = None
|
measured_by: Optional[int] = None
|
||||||
lot_number: Optional[str] = None
|
lot_number: Optional[str] = None
|
||||||
serial_number: Optional[str] = None
|
serial_number: Optional[str] = None
|
||||||
date_from: Optional[datetime] = None
|
date_from: UtcNaiveInput = None
|
||||||
date_to: Optional[datetime] = None
|
date_to: UtcNaiveInput = None
|
||||||
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
||||||
page: int = Field(1, ge=1)
|
page: int = Field(1, ge=1)
|
||||||
per_page: int = Field(50, ge=1, le=500)
|
per_page: int = Field(50, ge=1, le=500)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""API models for the live station monitor (#57)."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorSubtask(BaseModel):
|
||||||
|
"""Mean of the recent measurements of one quote, against its tolerance."""
|
||||||
|
|
||||||
|
subtask_id: int
|
||||||
|
description: str
|
||||||
|
unit: str
|
||||||
|
count: int
|
||||||
|
mean: float
|
||||||
|
ltl: Optional[float] = None
|
||||||
|
utl: Optional[float] = None
|
||||||
|
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
||||||
|
in_tolerance: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorEvent(BaseModel):
|
||||||
|
event_type: str
|
||||||
|
created_at: datetime
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorRun(BaseModel):
|
||||||
|
id: int
|
||||||
|
station_code: str
|
||||||
|
station_name: str
|
||||||
|
recipe_code: str
|
||||||
|
recipe_name: str
|
||||||
|
version_number: int
|
||||||
|
operator: str
|
||||||
|
status: str
|
||||||
|
started_at: datetime
|
||||||
|
duration_seconds: int
|
||||||
|
cycle_count: int
|
||||||
|
seconds_to_next_measurement: Optional[int] = None
|
||||||
|
overdue: bool
|
||||||
|
last_event: Optional[MonitorEvent] = None
|
||||||
|
# Counts over the last MONITOR_WINDOW measurements of the run.
|
||||||
|
measured: int
|
||||||
|
pass_count: int
|
||||||
|
warning_count: int
|
||||||
|
fail_count: int
|
||||||
|
subtasks: list[MonitorSubtask]
|
||||||
|
server_time: datetime
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Pydantic schemas for production runs and their events."""
|
||||||
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionRunCreate(BaseModel):
|
||||||
|
station_code: str = Field(..., min_length=1, max_length=100)
|
||||||
|
recipe_id: int = Field(..., gt=0)
|
||||||
|
version_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
lot_number: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
serial_number: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionEventResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
event_type: str
|
||||||
|
user_id: int
|
||||||
|
supervisor_id: Optional[int]
|
||||||
|
task_id: Optional[int] = None
|
||||||
|
note: Optional[str]
|
||||||
|
created_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionRunResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
station_id: int
|
||||||
|
recipe_id: int
|
||||||
|
version_id: int
|
||||||
|
operator_id: int
|
||||||
|
lot_number: Optional[str]
|
||||||
|
serial_number: Optional[str]
|
||||||
|
status: str
|
||||||
|
measurement_interval_minutes: Optional[int]
|
||||||
|
next_measurement_at: Optional[UtcDateTime]
|
||||||
|
cycle_count: int
|
||||||
|
started_at: UtcDateTime
|
||||||
|
paused_at: Optional[UtcDateTime]
|
||||||
|
closed_at: Optional[UtcDateTime]
|
||||||
|
closed_by: Optional[int]
|
||||||
|
statistics_path: Optional[str] = None
|
||||||
|
|
||||||
|
# Derived server-side so every client agrees on the countdown regardless of
|
||||||
|
# clock skew. Negative once the interval has elapsed: how long the run has been
|
||||||
|
# overdue is a fact the operator must see, not deduce.
|
||||||
|
seconds_to_next_measurement: Optional[int] = None
|
||||||
|
overdue: bool = False
|
||||||
|
server_time: Optional[UtcDateTime] = None
|
||||||
|
|
||||||
|
# The measurement tasks of this run's version, in execution order. The client
|
||||||
|
# needs them to bring the operator back to the measurement when the interval
|
||||||
|
# expires, whichever screen they are on, and to know which task closes a cycle.
|
||||||
|
measurement_task_ids: list[int] = Field(default_factory=list)
|
||||||
|
# #53: task 'note' con at_close, da compilare prima della modale del capoturno.
|
||||||
|
close_task_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||||
|
events: list[ProductionEventResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SupervisorAction(BaseModel):
|
||||||
|
"""Credentials of the supervisor authorising a line stop or a close."""
|
||||||
|
|
||||||
|
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||||
|
supervisor_username: Optional[str] = Field(None, min_length=1)
|
||||||
|
supervisor_password: Optional[str] = Field(None, min_length=1)
|
||||||
|
supervisor_user_id: Optional[int] = Field(None, gt=0)
|
||||||
|
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _one_credential_pair(self):
|
||||||
|
by_password = self.supervisor_username and self.supervisor_password
|
||||||
|
by_pin = self.supervisor_user_id and self.supervisor_pin
|
||||||
|
if not (by_password or by_pin):
|
||||||
|
raise ValueError(
|
||||||
|
"supervisor credentials required: username+password or user_id+pin"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CycleCompletePayload(BaseModel):
|
||||||
|
# Which measurement task was just finished. The interval restarts only on the
|
||||||
|
# last one of the recipe; omitting it closes the cycle outright.
|
||||||
|
task_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RemeasurePayload(BaseModel):
|
||||||
|
"""The piece was turned over: measure it again inside the same cycle."""
|
||||||
|
|
||||||
|
task_id: Optional[int] = Field(default=None, gt=0)
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
ProductionEventType = Literal[
|
||||||
|
"start", "cycle_completed", "task_measured", "remeasure",
|
||||||
|
"line_stop", "resume", "close",
|
||||||
|
]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for Recipe and RecipeVersion operations."""
|
"""Pydantic schemas for Recipe and RecipeVersion operations."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -15,18 +15,32 @@ class RecipeCreate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||||
|
# Rules of the recipe: what the operator must supply, and how a value may be
|
||||||
|
# entered. Manual input defaults to forbidden - the caliper is the instrument.
|
||||||
|
requires_lot: bool = False
|
||||||
|
requires_serial: bool = False
|
||||||
|
allow_manual_input: bool = False
|
||||||
# Optional task-level fields for the initial technical drawing
|
# Optional task-level fields for the initial technical drawing
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict] = None
|
annotations_json: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeDuplicate(BaseModel):
|
||||||
|
"""Schema for duplicating a recipe (#52): only the new code and name."""
|
||||||
|
code: str = Field(..., min_length=1, max_length=100)
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
class RecipeUpdate(BaseModel):
|
class RecipeUpdate(BaseModel):
|
||||||
"""Schema for updating a recipe (creates new version)."""
|
"""Schema for updating a recipe (creates new version)."""
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||||
|
requires_lot: Optional[bool] = None
|
||||||
|
requires_serial: Optional[bool] = None
|
||||||
|
allow_manual_input: Optional[bool] = None
|
||||||
change_notes: Optional[str] = None
|
change_notes: Optional[str] = None
|
||||||
# Task-level fields: saved to the first task of the new version
|
# Task-level fields: saved to the first task of the new version
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
@@ -43,7 +57,7 @@ class RecipeVersionResponse(BaseModel):
|
|||||||
version_number: int
|
version_number: int
|
||||||
is_current: bool
|
is_current: bool
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
change_notes: Optional[str] = None
|
change_notes: Optional[str] = None
|
||||||
tasks: list["TaskResponse"] = []
|
tasks: list["TaskResponse"] = []
|
||||||
|
|
||||||
@@ -58,8 +72,13 @@ class RecipeResponse(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
measurement_interval_minutes: Optional[int] = None
|
measurement_interval_minutes: Optional[int] = None
|
||||||
|
# The operator's screen reads these to know whether to ask for lot and serial
|
||||||
|
# before starting, and whether to offer the keypad at all.
|
||||||
|
requires_lot: bool = False
|
||||||
|
requires_serial: bool = False
|
||||||
|
allow_manual_input: bool = False
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
active: bool
|
active: bool
|
||||||
current_version: Optional[RecipeVersionResponse] = None
|
current_version: Optional[RecipeVersionResponse] = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if not value.lower().startswith(("http://", "https://")):
|
||||||
|
raise ValueError("vision_worker_url must start with http:// or https://")
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
class StationCreate(BaseModel):
|
class StationCreate(BaseModel):
|
||||||
@@ -11,6 +23,10 @@ class StationCreate(BaseModel):
|
|||||||
location: Optional[str] = Field(default=None, max_length=255)
|
location: Optional[str] = Field(default=None, max_length=255)
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
active: bool = True
|
active: bool = True
|
||||||
|
# worker di visione della stazione (#45); vuoto = centrale
|
||||||
|
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||||
|
|
||||||
|
|
||||||
class StationUpdate(BaseModel):
|
class StationUpdate(BaseModel):
|
||||||
@@ -18,6 +34,10 @@ class StationUpdate(BaseModel):
|
|||||||
location: Optional[str] = Field(default=None, max_length=255)
|
location: Optional[str] = Field(default=None, max_length=255)
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
active: Optional[bool] = None
|
active: Optional[bool] = None
|
||||||
|
# worker di visione della stazione (#45); "" -> None riporta al centrale
|
||||||
|
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||||
|
|
||||||
|
|
||||||
class StationResponse(BaseModel):
|
class StationResponse(BaseModel):
|
||||||
@@ -28,8 +48,9 @@ class StationResponse(BaseModel):
|
|||||||
location: Optional[str]
|
location: Optional[str]
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
active: bool
|
active: bool
|
||||||
|
vision_worker_url: Optional[str] = None # #45
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
class StationRecipeAssignmentCreate(BaseModel):
|
class StationRecipeAssignmentCreate(BaseModel):
|
||||||
@@ -42,7 +63,7 @@ class StationRecipeAssignmentResponse(BaseModel):
|
|||||||
station_id: int
|
station_id: int
|
||||||
recipe_id: int
|
recipe_id: int
|
||||||
assigned_by: int
|
assigned_by: int
|
||||||
assigned_at: datetime
|
assigned_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
class RecipeSummary(BaseModel):
|
class RecipeSummary(BaseModel):
|
||||||
@@ -53,7 +74,18 @@ class RecipeSummary(BaseModel):
|
|||||||
active: bool
|
active: bool
|
||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
# The selection screen has to know before the operator starts: what a recipe
|
||||||
|
# demands is the difference between an enabled Avvia and a disabled one.
|
||||||
|
requires_lot: bool = False
|
||||||
|
requires_serial: bool = False
|
||||||
|
|
||||||
|
|
||||||
class StationWithRecipesResponse(StationResponse):
|
class StationWithRecipesResponse(StationResponse):
|
||||||
recipes: list[RecipeSummary] = Field(default_factory=list)
|
recipes: list[RecipeSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class StationResetResponse(BaseModel):
|
||||||
|
"""Outcome of clearing every recipe assignment of a station."""
|
||||||
|
|
||||||
|
station_id: int
|
||||||
|
removed: int
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for SPC statistics responses."""
|
"""Pydantic schemas for SPC statistics responses."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
|
|||||||
recipe_id: int
|
recipe_id: int
|
||||||
version_id: Optional[int] = None
|
version_id: Optional[int] = None
|
||||||
subtask_id: Optional[int] = None
|
subtask_id: Optional[int] = None
|
||||||
date_from: Optional[datetime] = None
|
date_from: UtcNaiveInput = None
|
||||||
date_to: Optional[datetime] = None
|
date_to: UtcNaiveInput = None
|
||||||
operator_id: Optional[int] = None
|
operator_id: Optional[int] = None
|
||||||
lot_number: Optional[str] = None
|
lot_number: Optional[str] = None
|
||||||
serial_number: Optional[str] = None
|
serial_number: Optional[str] = None
|
||||||
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
|
|||||||
class ControlChartData(BaseModel):
|
class ControlChartData(BaseModel):
|
||||||
"""Data for X-bar/R control chart."""
|
"""Data for X-bar/R control chart."""
|
||||||
values: list[float]
|
values: list[float]
|
||||||
timestamps: list[datetime]
|
timestamps: list[UtcDateTime]
|
||||||
mean: float
|
mean: float
|
||||||
ucl: float # Upper Control Limit
|
ucl: float # Upper Control Limit
|
||||||
lcl: float # Lower Control Limit
|
lcl: float # Lower Control Limit
|
||||||
@@ -50,7 +50,8 @@ class CapabilityData(BaseModel):
|
|||||||
pp: Optional[float] = None
|
pp: Optional[float] = None
|
||||||
ppk: Optional[float] = None
|
ppk: Optional[float] = None
|
||||||
mean: float
|
mean: float
|
||||||
std_dev: float
|
std_dev: float # overall sample std dev (n-1), used for Pp/Ppk
|
||||||
|
std_dev_within: Optional[float] = None # MRbar/1.128, used for Cp/Cpk
|
||||||
n: int
|
n: int
|
||||||
utl: Optional[float] = None
|
utl: Optional[float] = None
|
||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
@@ -59,7 +60,7 @@ class CapabilityData(BaseModel):
|
|||||||
|
|
||||||
class TrendData(BaseModel):
|
class TrendData(BaseModel):
|
||||||
"""Temporal trend data for capability indices."""
|
"""Temporal trend data for capability indices."""
|
||||||
dates: list[datetime]
|
dates: list[UtcDateTime]
|
||||||
cpk_values: list[Optional[float]]
|
cpk_values: list[Optional[float]]
|
||||||
ppk_values: list[Optional[float]]
|
ppk_values: list[Optional[float]]
|
||||||
n_per_period: list[int]
|
n_per_period: list[int]
|
||||||
|
|||||||
@@ -1,7 +1,108 @@
|
|||||||
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||||
from typing import Any, Optional
|
import re
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
|
||||||
|
|
||||||
|
# Kept in step with the ORM enum so the two cannot drift apart.
|
||||||
|
TaskType = Literal[TASK_TYPES]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_tolerance_order(
|
||||||
|
ltl: Optional[float],
|
||||||
|
lwl: Optional[float],
|
||||||
|
nominal: Optional[float],
|
||||||
|
uwl: Optional[float],
|
||||||
|
utl: Optional[float],
|
||||||
|
) -> None:
|
||||||
|
"""ltl <= lwl <= nominal <= uwl <= utl, checked only across the values set.
|
||||||
|
|
||||||
|
A one-sided tolerance - only utl, say - is not an error: what is wrong is
|
||||||
|
limits that invert the sequence they do share, like utl landing below ltl.
|
||||||
|
Shared by SubtaskCreate's own validator and by the router's partial-update
|
||||||
|
check (SubtaskUpdate has no fixed set of fields to compare).
|
||||||
|
"""
|
||||||
|
ordered = [
|
||||||
|
("ltl", ltl), ("lwl", lwl), ("nominal", nominal), ("uwl", uwl), ("utl", utl),
|
||||||
|
]
|
||||||
|
present = [(name, value) for name, value in ordered if value is not None]
|
||||||
|
for (prev_name, prev), (name, value) in zip(present, present[1:]):
|
||||||
|
if prev > value:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tolerance limits out of order: {prev_name}={prev} > {name}={value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# #55: campi extra di un task 'note'
|
||||||
|
MAX_NOTE_FIELDS = 20
|
||||||
|
_FIELD_KEY_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||||
|
NOTE_FIELD_TYPES = ("text", "number")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_note_fields(fields: Optional[list]) -> Optional[list[dict]]:
|
||||||
|
"""Valida e normalizza `fields` di un task 'note' (#55); lista vuota -> None.
|
||||||
|
|
||||||
|
Solleva ValueError con un messaggio leggibile (il router lo trasforma in 422).
|
||||||
|
"""
|
||||||
|
if not fields:
|
||||||
|
return None
|
||||||
|
if len(fields) > MAX_NOTE_FIELDS:
|
||||||
|
raise ValueError(f"Al massimo {MAX_NOTE_FIELDS} campi per task")
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for i, raw in enumerate(fields, start=1):
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError(f"Campo {i}: formato non valido")
|
||||||
|
key = raw.get("key")
|
||||||
|
if not isinstance(key, str) or not _FIELD_KEY_RE.match(key):
|
||||||
|
raise ValueError(
|
||||||
|
f"Campo {i}: chiave non valida (solo a-z, 0-9, _, da 1 a 40 caratteri)"
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
raise ValueError(f"Campo {i}: chiave '{key}' duplicata")
|
||||||
|
seen.add(key)
|
||||||
|
label = raw.get("label")
|
||||||
|
label = label.strip() if isinstance(label, str) else ""
|
||||||
|
if not 1 <= len(label) <= 100:
|
||||||
|
raise ValueError(f"Campo '{key}': etichetta da 1 a 100 caratteri")
|
||||||
|
ftype = raw.get("type")
|
||||||
|
if ftype not in NOTE_FIELD_TYPES:
|
||||||
|
raise ValueError(f"Campo '{key}': tipo deve essere text o number")
|
||||||
|
default = raw.get("default")
|
||||||
|
if default is not None:
|
||||||
|
if not isinstance(default, str):
|
||||||
|
raise ValueError(f"Campo '{key}': il valore predefinito deve essere testo")
|
||||||
|
if default == "":
|
||||||
|
default = None
|
||||||
|
if default is not None and ftype == "number":
|
||||||
|
try:
|
||||||
|
float(default.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Campo '{key}': il valore predefinito non e' un numero")
|
||||||
|
attachment = raw.get("attachment", False)
|
||||||
|
if not isinstance(attachment, bool):
|
||||||
|
raise ValueError(f"Campo '{key}': attachment deve essere true o false")
|
||||||
|
out.append({
|
||||||
|
"key": key, "label": label, "type": ftype,
|
||||||
|
"default": default, "attachment": attachment,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def check_task_loop_options(task_type: str, at_close: bool, fields: Optional[list]) -> None:
|
||||||
|
"""at_close e fields sono ammessi solo per i task 'note' (#53 #55)."""
|
||||||
|
if task_type != "note":
|
||||||
|
if at_close:
|
||||||
|
raise ValueError("'Alla chiusura' e' ammesso solo per i task di tipo note")
|
||||||
|
if fields:
|
||||||
|
raise ValueError("I campi extra sono ammessi solo per i task di tipo note")
|
||||||
|
|
||||||
|
|
||||||
|
def default_in_loop(task_type: str) -> bool:
|
||||||
|
"""in_loop quando non dichiarato: misure e check si', gli altri no (#53)."""
|
||||||
|
return task_type in DEFAULT_LOOP_TASK_TYPES
|
||||||
|
|
||||||
|
|
||||||
class SubtaskCreate(BaseModel):
|
class SubtaskCreate(BaseModel):
|
||||||
@@ -16,6 +117,12 @@ class SubtaskCreate(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: str = Field("mm", max_length=20)
|
unit: str = Field("mm", max_length=20)
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_tolerance_order(self) -> "SubtaskCreate":
|
||||||
|
validate_tolerance_order(self.ltl, self.lwl, self.nominal, self.uwl, self.utl)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class SubtaskUpdate(BaseModel):
|
class SubtaskUpdate(BaseModel):
|
||||||
@@ -30,6 +137,7 @@ class SubtaskUpdate(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: Optional[str] = Field(None, max_length=20)
|
unit: Optional[str] = Field(None, max_length=20)
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SubtaskResponse(BaseModel):
|
class SubtaskResponse(BaseModel):
|
||||||
@@ -48,27 +156,39 @@ class SubtaskResponse(BaseModel):
|
|||||||
ltl: Optional[float] = None
|
ltl: Optional[float] = None
|
||||||
unit: str
|
unit: str
|
||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
|
vision_output: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskCreate(BaseModel):
|
||||||
"""Schema for creating a task."""
|
"""Schema for creating a task."""
|
||||||
title: str = Field(..., min_length=1, max_length=255)
|
title: str = Field(..., min_length=1, max_length=255)
|
||||||
|
task_type: TaskType = "note"
|
||||||
directive: Optional[str] = None
|
directive: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
|
# #53: assente = default dal tipo (misure e check nel ciclo)
|
||||||
|
in_loop: Optional[bool] = None
|
||||||
|
at_close: bool = False
|
||||||
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
subtasks: list[SubtaskCreate] = []
|
subtasks: list[SubtaskCreate] = []
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
"""Schema for updating a task."""
|
"""Schema for updating a task."""
|
||||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
task_type: Optional[TaskType] = None
|
||||||
directive: Optional[str] = None
|
directive: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
|
in_loop: Optional[bool] = None
|
||||||
|
at_close: Optional[bool] = None
|
||||||
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskResponse(BaseModel):
|
class TaskResponse(BaseModel):
|
||||||
@@ -79,12 +199,17 @@ class TaskResponse(BaseModel):
|
|||||||
version_id: int
|
version_id: int
|
||||||
recipe_id: Optional[int] = None
|
recipe_id: Optional[int] = None
|
||||||
order_index: int
|
order_index: int
|
||||||
|
task_type: str
|
||||||
title: str
|
title: str
|
||||||
directive: Optional[str] = None
|
directive: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
file_path: Optional[str] = None
|
file_path: Optional[str] = None
|
||||||
file_type: Optional[str] = None
|
file_type: Optional[str] = None
|
||||||
annotations_json: Optional[dict[str, Any]] = None
|
annotations_json: Optional[dict[str, Any]] = None
|
||||||
|
vision_json: Optional[dict] = None
|
||||||
|
in_loop: bool = False
|
||||||
|
at_close: bool = False
|
||||||
|
fields: Optional[list[dict[str, Any]]] = None
|
||||||
subtasks: list[SubtaskResponse] = []
|
subtasks: list[SubtaskResponse] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Pydantic schemas for task results (#54 check OK/NOK, #55 note con campi)."""
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResultAttachment(BaseModel):
|
||||||
|
"""Un allegato: `path` e' il percorso restituito da /api/files/upload."""
|
||||||
|
key: Optional[str] = Field(None, max_length=40)
|
||||||
|
path: str = Field(..., min_length=1, max_length=500)
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResultCreate(BaseModel):
|
||||||
|
task_id: int = Field(..., gt=0)
|
||||||
|
production_run_id: Optional[int] = Field(None, gt=0)
|
||||||
|
outcome: Literal["ok", "nok", "done"]
|
||||||
|
note: Optional[str] = None
|
||||||
|
values: Optional[dict[str, str]] = None
|
||||||
|
attachments: Optional[list[TaskResultAttachment]] = Field(None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResultResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
task_id: int
|
||||||
|
version_id: int
|
||||||
|
production_run_id: Optional[int] = None
|
||||||
|
user_id: int
|
||||||
|
outcome: str
|
||||||
|
note: Optional[str] = None
|
||||||
|
values: Optional[dict[str, str]] = None
|
||||||
|
attachments: Optional[list[TaskResultAttachment]] = None
|
||||||
|
created_at: UtcDateTime
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Shared Pydantic types for datetimes (#42): UTC in the DB, explicit +00:00 on the wire."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
from pydantic import AfterValidator, PlainSerializer
|
||||||
|
|
||||||
|
from src.backend.utils.tempo import as_utc_naive, to_utc_aware
|
||||||
|
|
||||||
|
UtcDateTime = Annotated[
|
||||||
|
datetime, PlainSerializer(lambda v: to_utc_aware(v).isoformat(), return_type=str)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Input: aware values are converted to UTC naive, naive is taken as UTC.
|
||||||
|
UtcNaiveInput = Annotated[Optional[datetime], AfterValidator(as_utc_naive)]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for User operations."""
|
"""Pydantic schemas for User operations."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -48,8 +48,10 @@ class UserResponse(BaseModel):
|
|||||||
language_pref: str
|
language_pref: str
|
||||||
theme_pref: str
|
theme_pref: str
|
||||||
active: bool
|
active: bool
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
last_login: Optional[datetime] = None
|
last_login: Optional[UtcDateTime] = None
|
||||||
|
# #59: solo il fatto che il PIN esista, mai l'hash
|
||||||
|
has_pin: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserPasswordChange(BaseModel):
|
class UserPasswordChange(BaseModel):
|
||||||
@@ -68,3 +70,21 @@ class LoginResponse(BaseModel):
|
|||||||
user: UserResponse
|
user: UserResponse
|
||||||
api_key: str
|
api_key: str
|
||||||
message: str = "Login successful"
|
message: str = "Login successful"
|
||||||
|
|
||||||
|
|
||||||
|
class PinUserItem(BaseModel):
|
||||||
|
"""Voce dell'elenco pubblico per il login con PIN (#59): solo id e nome."""
|
||||||
|
id: int
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class PinLoginRequest(BaseModel):
|
||||||
|
"""Login con PIN (#59)."""
|
||||||
|
user_id: int
|
||||||
|
pin: str = Field(..., pattern=r"^[0-9]{4}$")
|
||||||
|
|
||||||
|
|
||||||
|
class PinSet(BaseModel):
|
||||||
|
"""Imposta (4 cifre) o toglie (null) il PIN di un utente (#59)."""
|
||||||
|
pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Pydantic schemas for the vision execution response."""
|
||||||
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class VisionMeasurementResult(BaseModel):
|
||||||
|
"""One quote's outcome, as much of it as the client needs to show."""
|
||||||
|
subtask_id: int
|
||||||
|
value: float
|
||||||
|
pass_fail: str
|
||||||
|
|
||||||
|
|
||||||
|
class VisionExecuteResponse(BaseModel):
|
||||||
|
"""What `POST /api/vision/execute` returns: the run, and every quote it filled."""
|
||||||
|
vision_result_id: int
|
||||||
|
engine_version: str
|
||||||
|
measurements: list[VisionMeasurementResult]
|
||||||
|
# Esito VisionSuite (informativo: il verdetto lo danno utl/ltl), overlay
|
||||||
|
# in px immagine, URL dell'immagine salvata e strumenti falliti (#46).
|
||||||
|
outcome: str | None = None
|
||||||
|
overlay: list[dict] = []
|
||||||
|
image_url: str | None = None
|
||||||
|
failures: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class VisionRecipeOutput(BaseModel):
|
||||||
|
"""One numeric output a recipe offers to the quotes."""
|
||||||
|
key: str
|
||||||
|
quantity: str | None = None
|
||||||
|
unit: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VisionRecipeUploadResponse(BaseModel):
|
||||||
|
task_id: int
|
||||||
|
recipe_dir: str
|
||||||
|
files: list[str]
|
||||||
|
outputs: list[VisionRecipeOutput]
|
||||||
|
|
||||||
|
|
||||||
|
class VisionRecipeResponse(BaseModel):
|
||||||
|
task_id: int
|
||||||
|
recipe_dir: str | None = None
|
||||||
|
recipe: dict | None = None
|
||||||
|
files: list[str]
|
||||||
|
outputs: list[VisionRecipeOutput]
|
||||||
|
|
||||||
|
|
||||||
|
class VisionReferenceImageResponse(BaseModel):
|
||||||
|
"""One saved reference image, as `GET /api/vision/reference-images` lists it."""
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
task_id: int
|
||||||
|
path: str
|
||||||
|
note: str | None = None
|
||||||
|
station_id: int | None = None
|
||||||
|
device_code: str | None = None
|
||||||
|
acquired_at: UtcDateTime
|
||||||
@@ -1,22 +1,32 @@
|
|||||||
"""SQLAlchemy models for TieMeasureFlow."""
|
"""SQLAlchemy models for TieMeasureFlow."""
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||||
|
from src.backend.models.orm.task_result import TaskResult
|
||||||
from src.backend.models.orm.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from src.backend.models.orm.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
|
from src.backend.models.orm.production import ProductionRun, ProductionEvent
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
|
"ApiKey",
|
||||||
"Recipe",
|
"Recipe",
|
||||||
"RecipeVersion",
|
"RecipeVersion",
|
||||||
"RecipeTask",
|
"RecipeTask",
|
||||||
"RecipeSubtask",
|
"RecipeSubtask",
|
||||||
|
"TaskResult",
|
||||||
"Measurement",
|
"Measurement",
|
||||||
"AccessLog",
|
"AccessLog",
|
||||||
"SystemSetting",
|
"SystemSetting",
|
||||||
"RecipeVersionAudit",
|
"RecipeVersionAudit",
|
||||||
"Station",
|
"Station",
|
||||||
"StationRecipeAssignment",
|
"StationRecipeAssignment",
|
||||||
|
"ProductionRun",
|
||||||
|
"ProductionEvent",
|
||||||
|
"VisionResult",
|
||||||
|
"VisionReferenceImage",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""API key sessions: one row per login, stored as a SHA-256 hash."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(Base):
|
||||||
|
"""One API key per login session.
|
||||||
|
|
||||||
|
Only the SHA-256 hex digest is stored, never the key. Dates are naive UTC.
|
||||||
|
"""
|
||||||
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
@@ -38,16 +38,43 @@ class Measurement(Base):
|
|||||||
|
|
||||||
# Input method
|
# Input method
|
||||||
input_method: Mapped[str] = mapped_column(
|
input_method: Mapped[str] = mapped_column(
|
||||||
Enum("usb_caliper", "manual", name="input_method_enum"),
|
Enum("usb_caliper", "manual", "camera", name="input_method_enum"),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default="manual",
|
default="manual",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The vision execution that produced this value, when there was one. Null for
|
||||||
|
# everything taken by caliper or typed by hand.
|
||||||
|
vision_result_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("vision_results.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Time spent by the operator entering this value (client-reported)
|
# Time spent by the operator entering this value (client-reported)
|
||||||
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, nullable=True
|
Integer, nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The production this was taken during, when there was one. Nullable: rows
|
||||||
|
# predating production runs, and measurements taken outside a production, have
|
||||||
|
# none. This is what makes "the measurements of this production" a query, which
|
||||||
|
# fine produzione needs to emit the statistics file.
|
||||||
|
production_run_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("production_runs.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Who let an out-of-tolerance value stand, and when. Null on a value that is
|
||||||
|
# within tolerance, and null on one that is not and has not been authorised -
|
||||||
|
# which is exactly the state that stops the operator moving on to the next
|
||||||
|
# quote. Before this the supervisor's approval was a modal that closed: nothing
|
||||||
|
# recorded it, and nothing depended on it.
|
||||||
|
supervisor_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
authorised_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
# Timestamp
|
# Timestamp
|
||||||
measured_at: Mapped[datetime] = mapped_column(
|
measured_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, nullable=False, server_default=func.now(), index=True
|
DateTime, nullable=False, server_default=func.now(), index=True
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""ProductionRun and ProductionEvent models.
|
||||||
|
|
||||||
|
A production run is what an operator opens when production starts and a supervisor
|
||||||
|
closes when it ends. It is the thing that outlives a page: before this, the timer,
|
||||||
|
the cycle count and the "production started" flag were Alpine variables in
|
||||||
|
task_execute.html, and navigating between tasks - a full page load - destroyed them.
|
||||||
|
|
||||||
|
The measurement deadline is stored as an absolute timestamp (next_measurement_at)
|
||||||
|
rather than a remaining count. Any client can then derive the countdown from it, get
|
||||||
|
the same answer, survive a reload, and tell how far *past* the interval it is by
|
||||||
|
letting the difference go negative.
|
||||||
|
|
||||||
|
With one station app installed per PC (see the install architecture of 28/07) the
|
||||||
|
database is the only place this state can live: the API keeps nothing in process
|
||||||
|
memory.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
DateTime, Enum, ForeignKey, Index, Integer, SmallInteger, String, Text,
|
||||||
|
UniqueConstraint, func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
|
||||||
|
|
||||||
|
RUN_STATUSES = ("running", "paused", "closed")
|
||||||
|
# task_measured marks a measurement task finished inside a cycle that is not over
|
||||||
|
# yet; only the last one produces cycle_completed and restarts the interval.
|
||||||
|
# remeasure is the piece being turned and measured again without closing the cycle.
|
||||||
|
EVENT_TYPES = (
|
||||||
|
"start", "cycle_completed", "task_measured", "remeasure",
|
||||||
|
"line_stop", "resume", "close",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionRun(Base):
|
||||||
|
__tablename__ = "production_runs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
station_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("stations.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
recipe_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipes.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
version_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
operator_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Traceability travels with the run instead of the Flask session, so it stays
|
||||||
|
# attached to every measurement taken during it.
|
||||||
|
lot_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
serial_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||||
|
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
Enum(*RUN_STATUSES, name="production_run_status_enum"),
|
||||||
|
nullable=False,
|
||||||
|
default="running",
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Snapshot of the recipe interval at start: editing the recipe mid-run must not
|
||||||
|
# move the deadline of a production already under way.
|
||||||
|
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(
|
||||||
|
SmallInteger, nullable=True
|
||||||
|
)
|
||||||
|
# When the next measurement falls due. Null when the recipe has no interval.
|
||||||
|
# Past this instant the run is overdue, and by how much is simply now - this.
|
||||||
|
next_measurement_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime, nullable=True, index=True
|
||||||
|
)
|
||||||
|
cycle_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
closed_by: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=True
|
||||||
|
)
|
||||||
|
# Statistics file emitted when the run was closed, relative to the upload dir.
|
||||||
|
statistics_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Mirrors station_id while the run is open and goes NULL when it closes. A unique
|
||||||
|
# index on it lets the database - not a check-then-insert race - guarantee that a
|
||||||
|
# station never has two open runs. Repeated NULLs do not collide in a unique index.
|
||||||
|
active_station_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
|
||||||
|
station: Mapped["Station"] = relationship(lazy="selectin")
|
||||||
|
recipe: Mapped["Recipe"] = relationship(lazy="selectin")
|
||||||
|
version: Mapped["RecipeVersion"] = relationship(lazy="selectin")
|
||||||
|
events: Mapped[list["ProductionEvent"]] = relationship(
|
||||||
|
back_populates="run",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="ProductionEvent.created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("active_station_id", name="uq_production_runs_active_station"),
|
||||||
|
Index("ix_production_runs_station_status", "station_id", "status"),
|
||||||
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
return self.status in ("running", "paused")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ProductionRun {self.id} station={self.station_id} {self.status}>"
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionEvent(Base):
|
||||||
|
"""Append-only trace of what happened during a run.
|
||||||
|
|
||||||
|
This is the record that did not exist before: without it there is no history of
|
||||||
|
a production, and nothing for the statistics file or the ERP hand-off to stand on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "production_events"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
run_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("production_runs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
|
)
|
||||||
|
event_type: Mapped[str] = mapped_column(
|
||||||
|
Enum(*EVENT_TYPES, name="production_event_type_enum"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# Who authorised it, when the action needed a supervisor (fermo linea, chiusura).
|
||||||
|
supervisor_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=True
|
||||||
|
)
|
||||||
|
# Which task the event is about, for the ones that happen inside a cycle. A
|
||||||
|
# task_measured or a remeasure that does not say which task is not a record of
|
||||||
|
# anything; null for the events that concern the run as a whole.
|
||||||
|
task_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id"), nullable=True
|
||||||
|
)
|
||||||
|
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now(), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
run: Mapped["ProductionRun"] = relationship(back_populates="events")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
{"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ProductionEvent run={self.run_id} {self.event_type}>"
|
||||||
@@ -26,6 +26,22 @@ class Recipe(Base):
|
|||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
|
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
|
||||||
|
|
||||||
|
# Traceability, decided when the recipe is written rather than left to whoever
|
||||||
|
# is at the machine: a measurement without the lot it belongs to cannot be
|
||||||
|
# traced back afterwards, and tracing back is half of what the system is for.
|
||||||
|
requires_lot: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="0"
|
||||||
|
)
|
||||||
|
requires_serial: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="0"
|
||||||
|
)
|
||||||
|
# Whether a value may be typed instead of read from the caliper. Forbidden by
|
||||||
|
# default: without the rule, a value that happens to be in tolerance can simply
|
||||||
|
# be entered by hand, which is the case this setting exists to prevent.
|
||||||
|
allow_manual_input: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="0"
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
versions: Mapped[list["RecipeVersion"]] = relationship(
|
versions: Mapped[list["RecipeVersion"]] = relationship(
|
||||||
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
|
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class Station(Base):
|
|||||||
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
# URL del worker di visione della stazione (#45); NULL = worker centrale
|
||||||
|
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, nullable=False, server_default=func.now()
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
Boolean, DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -12,6 +12,26 @@ if TYPE_CHECKING:
|
|||||||
from src.backend.models.orm.recipe import RecipeVersion
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
|
||||||
|
|
||||||
|
# What a task is, declared by whoever writes the recipe instead of guessed from its
|
||||||
|
# contents. The last two are not built yet; they are in the enum from the start
|
||||||
|
# because widening a MySQL enum later is an ALTER on a live table.
|
||||||
|
# 'check' = Controllo OK/NOK, senza quote (#54).
|
||||||
|
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure", "check")
|
||||||
|
|
||||||
|
# Types the operator screen treats as "there are quotes to take here".
|
||||||
|
# dxf_compare produce quote come camera_measure (#46)
|
||||||
|
MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare")
|
||||||
|
|
||||||
|
# Tipi che entrano nel ciclo di produzione se in_loop non e' dichiarato (#53):
|
||||||
|
# le misure di sempre piu' il controllo OK/NOK.
|
||||||
|
DEFAULT_LOOP_TASK_TYPES = MEASURING_TASK_TYPES + ("check",)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_in_loop(context) -> bool:
|
||||||
|
"""Default alla creazione di in_loop: dipende dal tipo del task (#53)."""
|
||||||
|
return context.get_current_parameters().get("task_type", "note") in DEFAULT_LOOP_TASK_TYPES
|
||||||
|
|
||||||
|
|
||||||
class RecipeTask(Base):
|
class RecipeTask(Base):
|
||||||
__tablename__ = "recipe_tasks"
|
__tablename__ = "recipe_tasks"
|
||||||
|
|
||||||
@@ -20,6 +40,16 @@ class RecipeTask(Base):
|
|||||||
Integer, ForeignKey("recipe_versions.id"), nullable=False
|
Integer, ForeignKey("recipe_versions.id"), nullable=False
|
||||||
)
|
)
|
||||||
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
# Declared, not deduced. The old rule - "has subtasks, therefore a measurement" -
|
||||||
|
# meant a measurement task whose quotes had not been entered yet behaved as a
|
||||||
|
# note, so the system acted differently depending on how finished the recipe was.
|
||||||
|
task_type: Mapped[str] = mapped_column(
|
||||||
|
Enum(*TASK_TYPES, name="task_type_enum"),
|
||||||
|
nullable=False,
|
||||||
|
default="note",
|
||||||
|
server_default="note",
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
@@ -28,6 +58,24 @@ class RecipeTask(Base):
|
|||||||
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
Enum("image", "pdf", name="file_type_enum"), nullable=True
|
||||||
)
|
)
|
||||||
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
annotations_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
# The vision graph, serialised by vs-task. Opaque here on purpose: the
|
||||||
|
# server routes it and never interprets it, which is what keeps VisionSuite
|
||||||
|
# out of this container.
|
||||||
|
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||||
|
# meta della ricetta VisionSuite v2 (#46).
|
||||||
|
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
|
||||||
|
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
||||||
|
in_loop: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=_default_in_loop, server_default="0"
|
||||||
|
)
|
||||||
|
# #53: task 'note' compilato alla fine produzione, prima della modale del capoturno.
|
||||||
|
at_close: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False, server_default="0"
|
||||||
|
)
|
||||||
|
# #55: campi extra del task 'note' ([{key,label,type,default,attachment}]).
|
||||||
|
fields: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||||
@@ -70,6 +118,12 @@ class RecipeSubtask(Base):
|
|||||||
|
|
||||||
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
||||||
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# Which output of the vision graph fills this quote: "<tool_id>.<output>".
|
||||||
|
# A link is an id, not a pointer - the same rule vs-task follows, and for
|
||||||
|
# the same reason: a pointer does not survive being written to disk.
|
||||||
|
vision_output: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(120), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""TaskResult: l'esito registrato da un task 'check' (OK/NOK) o 'note' (#54 #55)."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
TASK_OUTCOMES = ("ok", "nok", "done")
|
||||||
|
|
||||||
|
|
||||||
|
class TaskResult(Base):
|
||||||
|
__tablename__ = "task_results"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
|
)
|
||||||
|
# Copiata dal task: la versione in cui l'esito e' stato dato non cambia piu'.
|
||||||
|
version_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
production_run_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("production_runs.id", ondelete="SET NULL"),
|
||||||
|
nullable=True, index=True,
|
||||||
|
)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
outcome: Mapped[str] = mapped_column(
|
||||||
|
Enum(*TASK_OUTCOMES, name="task_outcome_enum"), nullable=False
|
||||||
|
)
|
||||||
|
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
# {chiave del campo: valore stringa}; allegati: [{key, path, name}]
|
||||||
|
values: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
attachments: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TaskResult task={self.task_id} outcome={self.outcome}>"
|
||||||
@@ -22,6 +22,13 @@ class User(Base):
|
|||||||
# Admin flag separate from roles
|
# Admin flag separate from roles
|
||||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# PIN a 4 cifre (#59): solo l'hash; contatore errori e fine blocco per il lockout
|
||||||
|
pin_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
pin_failed_count: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0, server_default="0"
|
||||||
|
)
|
||||||
|
pin_locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
# User preferences
|
# User preferences
|
||||||
language_pref: Mapped[str] = mapped_column(
|
language_pref: Mapped[str] = mapped_column(
|
||||||
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
||||||
@@ -32,7 +39,6 @@ class User(Base):
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
api_key: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
|
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
@@ -40,6 +46,11 @@ class User(Base):
|
|||||||
)
|
)
|
||||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_pin(self) -> bool:
|
||||||
|
"""True se l'utente ha un PIN impostato (#59); serve a UserResponse.has_pin."""
|
||||||
|
return self.pin_hash is not None
|
||||||
|
|
||||||
def has_role(self, role: str) -> bool:
|
def has_role(self, role: str) -> bool:
|
||||||
"""Check if user has a specific role."""
|
"""Check if user has a specific role."""
|
||||||
return role in (self.roles or [])
|
return role in (self.roles or [])
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""One row per vision execution - not per quote."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class VisionResult(Base):
|
||||||
|
__tablename__ = "vision_results"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
|
)
|
||||||
|
image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
engine_version: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
executed_on: Mapped[str] = mapped_column(
|
||||||
|
Enum("server", "station", name="vision_executed_on_enum"), nullable=False
|
||||||
|
)
|
||||||
|
station_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("stations.id"), nullable=True
|
||||||
|
)
|
||||||
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
# URL del worker effettivamente chiamato (#45), copiato: la stazione puo' cambiarlo dopo
|
||||||
|
worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
# Copied, not referenced: a calibration redone six months from now must not
|
||||||
|
# rewrite what a measurement already taken means. Same for the graph.
|
||||||
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
graph_snapshot: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
duration_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
executed_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<VisionResult task={self.task_id} engine={self.engine_version}>"
|
||||||
|
|
||||||
|
|
||||||
|
class VisionReferenceImage(Base):
|
||||||
|
"""An image kept so a graph can be composed, tried and re-tried without a
|
||||||
|
camera - which is exactly what VisionSuite's source abstraction is for."""
|
||||||
|
|
||||||
|
__tablename__ = "vision_reference_images"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, index=True,
|
||||||
|
)
|
||||||
|
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
station_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, ForeignKey("stations.id"), nullable=True
|
||||||
|
)
|
||||||
|
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
engine_version: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
note: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
expected_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
acquired_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<VisionReferenceImage task={self.task_id} path={self.path}>"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
"""AI service for parsing technical sheets via OpenRouter."""
|
"""AI service for parsing technical sheets via OpenRouter."""
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -10,6 +11,17 @@ from src.backend.config import settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AIResponseError(Exception):
|
||||||
|
"""The AI model's response could not be used: missing fields or invalid JSON.
|
||||||
|
|
||||||
|
Distinct from ValueError (raised for our own misconfiguration, e.g. a
|
||||||
|
missing API key) so the router can tell "we are broken" (400) apart from
|
||||||
|
"the upstream AI service is broken" (502) instead of both landing on the
|
||||||
|
same except clause.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """Sei un assistente specializzato nell'analisi di schede tecniche industriali.
|
SYSTEM_PROMPT = """Sei un assistente specializzato nell'analisi di schede tecniche industriali.
|
||||||
Analizza il testo estratto da una scheda tecnica e identifica i blocchi operativi distinti.
|
Analizza il testo estratto da una scheda tecnica e identifica i blocchi operativi distinti.
|
||||||
Per ogni blocco, estrai:
|
Per ogni blocco, estrai:
|
||||||
@@ -36,7 +48,9 @@ def extract_text_from_pdf(pdf_bytes: bytes) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
||||||
text = extract_text_from_pdf(pdf_bytes)
|
# pdfplumber parses synchronously and can take a while on a big PDF: off
|
||||||
|
# the event loop, or one upload stalls every other request being served.
|
||||||
|
text = await asyncio.to_thread(extract_text_from_pdf, pdf_bytes)
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -62,7 +76,10 @@ async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
content = data["choices"][0]["message"]["content"].strip()
|
try:
|
||||||
|
content = data["choices"][0]["message"]["content"].strip()
|
||||||
|
except (KeyError, IndexError, TypeError, AttributeError) as e:
|
||||||
|
raise AIResponseError(f"Risposta del modello AI malformata: {e}") from e
|
||||||
|
|
||||||
# Strip markdown fences if present
|
# Strip markdown fences if present
|
||||||
if content.startswith("```"):
|
if content.startswith("```"):
|
||||||
@@ -71,4 +88,10 @@ async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
|||||||
content = content[:-3]
|
content = content[:-3]
|
||||||
content = content.strip()
|
content = content.strip()
|
||||||
|
|
||||||
return json.loads(content)
|
try:
|
||||||
|
return json.loads(content)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
# json.JSONDecodeError is itself a ValueError: without this, the
|
||||||
|
# router's `except ValueError` (meant for our own config errors)
|
||||||
|
# would misreport a broken upstream response as our fault (400).
|
||||||
|
raise AIResponseError(f"Risposta del modello AI non e' JSON valido: {e}") from e
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
"""Authentication service - password hashing, API key management."""
|
"""Authentication service - password hashing, API key management."""
|
||||||
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from sqlalchemy import select, update
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import delete, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
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.models.orm.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +31,13 @@ def generate_api_key() -> str:
|
|||||||
return secrets.token_urlsafe(48) # 64 chars base64
|
return secrets.token_urlsafe(48) # 64 chars base64
|
||||||
|
|
||||||
|
|
||||||
|
# A precomputed, unused bcrypt hash. When the username does not match any
|
||||||
|
# user, authenticate_user still runs bcrypt against this instead of
|
||||||
|
# returning immediately, so "unknown username" costs the same as "wrong
|
||||||
|
# password" and a caller cannot use response time to enumerate usernames.
|
||||||
|
_DUMMY_PASSWORD_HASH = "$2b$12$BLtwrR2COyd5W/UWfxp7BOc7OHua9jazo6zZdWOwRD9PV.pyaqKZC"
|
||||||
|
|
||||||
|
|
||||||
async def authenticate_user(
|
async def authenticate_user(
|
||||||
db: AsyncSession, username: str, password: str
|
db: AsyncSession, username: str, password: str
|
||||||
) -> User | None:
|
) -> User | None:
|
||||||
@@ -34,29 +46,181 @@ async def authenticate_user(
|
|||||||
select(User).where(User.username == username, User.active == True)
|
select(User).where(User.username == username, User.active == True)
|
||||||
)
|
)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if user is None or not verify_password(password, user.password_hash):
|
if user is None:
|
||||||
|
verify_password(password, _DUMMY_PASSWORD_HASH)
|
||||||
|
return None
|
||||||
|
if not verify_password(password, user.password_hash):
|
||||||
return None
|
return None
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def login_user(db: AsyncSession, user: User) -> str:
|
# #59: PIN a 4 cifre. Hash identico alle password (bcrypt); 5 errori -> blocco 15 minuti.
|
||||||
"""Generate API key and update last_login for user."""
|
PIN_MAX_FAILURES = 5
|
||||||
api_key = generate_api_key()
|
PIN_LOCK_MINUTES = 15
|
||||||
|
|
||||||
|
|
||||||
|
async def set_user_pin(db: AsyncSession, user: User, pin: str | None) -> None:
|
||||||
|
"""Imposta o toglie (None) il PIN; azzera contatore e blocco. Admin esclusi (#59)."""
|
||||||
|
if pin is not None and user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Admin users cannot have a PIN",
|
||||||
|
)
|
||||||
|
user.pin_hash = hash_password(pin) if pin is not None else None
|
||||||
|
user.pin_failed_count = 0
|
||||||
|
user.pin_locked_until = None
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate_pin(db: AsyncSession, user_id: int, pin: str) -> User:
|
||||||
|
"""Verifica user_id+PIN con blocco dopo 5 errori consecutivi (#59).
|
||||||
|
|
||||||
|
401 PIN errato (o utente non idoneo: stessa risposta, niente enumerazione),
|
||||||
|
423 utente bloccato. Il contatore va committato prima di sollevare: get_db fa
|
||||||
|
rollback su eccezione e l'errore andrebbe perso.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.id == user_id, User.active == True) # noqa: E712
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None or user.is_admin or user.pin_hash is None:
|
||||||
|
verify_password(pin, _DUMMY_PASSWORD_HASH) # stesso costo di un PIN errato
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN"
|
||||||
|
)
|
||||||
|
now = utcnow()
|
||||||
|
if user.pin_locked_until is not None:
|
||||||
|
if user.pin_locked_until > now:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_423_LOCKED,
|
||||||
|
detail="PIN locked until "
|
||||||
|
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
+ " UTC",
|
||||||
|
)
|
||||||
|
# blocco scaduto: si riparte da zero
|
||||||
|
user.pin_locked_until = None
|
||||||
|
user.pin_failed_count = 0
|
||||||
|
if verify_password(pin, user.pin_hash):
|
||||||
|
if user.pin_failed_count:
|
||||||
|
user.pin_failed_count = 0
|
||||||
|
await db.flush()
|
||||||
|
return user
|
||||||
|
user.pin_failed_count += 1
|
||||||
|
locked = user.pin_failed_count >= PIN_MAX_FAILURES
|
||||||
|
if locked:
|
||||||
|
user.pin_locked_until = now + timedelta(minutes=PIN_LOCK_MINUTES)
|
||||||
|
await db.commit()
|
||||||
|
if locked:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_423_LOCKED,
|
||||||
|
detail="PIN locked until "
|
||||||
|
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
+ " UTC",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN")
|
||||||
|
|
||||||
|
|
||||||
|
async def authorise_supervisor(
|
||||||
|
db: AsyncSession,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
user_id: int | None = None,
|
||||||
|
pin: str | None = None,
|
||||||
|
) -> User:
|
||||||
|
"""Check the credentials of the supervisor authorising something.
|
||||||
|
|
||||||
|
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
||||||
|
out-of-tolerance value allowed to stand. Lives here rather than beside any one
|
||||||
|
of them because it is the same question every time - who is this, and are they
|
||||||
|
allowed to say yes.
|
||||||
|
|
||||||
|
Uses authenticate_user rather than a full login: logging in would issue a
|
||||||
|
needless API key for the supervisor.
|
||||||
|
"""
|
||||||
|
if user_id is not None and pin is not None:
|
||||||
|
# #59: via PIN, stesso blocco del login con PIN (401/423 sollevati da li')
|
||||||
|
supervisor = await authenticate_pin(db, user_id, pin)
|
||||||
|
elif username is not None and password is not None:
|
||||||
|
supervisor = await authenticate_user(db, username, password)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Supervisor credentials required",
|
||||||
|
)
|
||||||
|
if supervisor is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid supervisor credentials",
|
||||||
|
)
|
||||||
|
if not supervisor.has_role("Supervisor") and not supervisor.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="User is not authorised as supervisor",
|
||||||
|
)
|
||||||
|
return supervisor
|
||||||
|
|
||||||
|
|
||||||
|
def hash_api_key(key: str) -> str:
|
||||||
|
"""SHA-256 hex digest of an API key; this is all the DB ever stores."""
|
||||||
|
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def issue_api_key(db: AsyncSession, user: User) -> str:
|
||||||
|
"""Create a new session key for the user and return it in clear (once)."""
|
||||||
|
key = generate_api_key()
|
||||||
|
now = utcnow()
|
||||||
|
# Housekeeping: drop this user's expired keys so the table does not grow forever.
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(User)
|
delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
|
||||||
.where(User.id == user.id)
|
)
|
||||||
.values(api_key=api_key, last_login=datetime.utcnow())
|
db.add(
|
||||||
|
ApiKey(
|
||||||
|
user_id=user.id,
|
||||||
|
key_hash=hash_api_key(key),
|
||||||
|
created_at=now,
|
||||||
|
expires_at=now + timedelta(hours=settings.api_key_ttl_hours),
|
||||||
|
last_used_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_all_keys(db: AsyncSession, user_id: int) -> None:
|
||||||
|
"""Invalidate every session key of a user."""
|
||||||
|
await db.execute(delete(ApiKey).where(ApiKey.user_id == user_id))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def login_user(db: AsyncSession, user: User) -> str:
|
||||||
|
"""Issue a new API key for this login and update last_login.
|
||||||
|
|
||||||
|
One key per login: the same account used from several stations at once
|
||||||
|
(#25) keeps every station working, because no login revokes another's key.
|
||||||
|
"""
|
||||||
|
api_key = await issue_api_key(db, user)
|
||||||
|
await db.execute(
|
||||||
|
update(User).where(User.id == user.id).values(last_login=utcnow())
|
||||||
)
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return api_key
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
async def logout_user(db: AsyncSession, user: User) -> None:
|
async def logout_user(
|
||||||
"""Invalidate user's API key."""
|
db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
|
||||||
await db.execute(
|
) -> None:
|
||||||
update(User).where(User.id == user.id).values(api_key=None)
|
"""Log out. Deletes only the key of this session; all_sessions=True
|
||||||
)
|
deletes every key of the user."""
|
||||||
await db.flush()
|
if all_sessions:
|
||||||
|
await revoke_all_keys(db, user.id)
|
||||||
|
return
|
||||||
|
if key:
|
||||||
|
await db.execute(
|
||||||
|
delete(ApiKey).where(
|
||||||
|
ApiKey.user_id == user.id, ApiKey.key_hash == hash_api_key(key)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
async def create_user(
|
async def create_user(
|
||||||
@@ -87,11 +251,7 @@ async def create_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
|
async def regenerate_api_key(db: AsyncSession, user: User) -> str:
|
||||||
"""Regenerate API key for a user."""
|
"""Revoke every key of the user and issue a fresh one."""
|
||||||
new_key = generate_api_key()
|
await revoke_all_keys(db, user.id)
|
||||||
await db.execute(
|
return await issue_api_key(db, user)
|
||||||
update(User).where(User.id == user_id).values(api_key=new_key)
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
return new_key
|
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
"""Measurement service - pass/fail calculation, data storage."""
|
"""Measurement service - pass/fail calculation, data storage."""
|
||||||
|
import math
|
||||||
|
from datetime import datetime
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import select
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.models.orm.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
from src.backend.models.orm.task import RecipeSubtask
|
from src.backend.models.orm.production import ProductionEvent
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
|
from src.backend.models.orm.task import (
|
||||||
|
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
|
||||||
|
)
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
|
||||||
|
|
||||||
|
# What the Measurement.value column (DECIMAL(12,6)) can actually hold.
|
||||||
|
_MAX_ABS_VALUE = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_measured_value(value: float) -> None:
|
||||||
|
"""Refuse a value that would corrupt the verdict or the INSERT.
|
||||||
|
|
||||||
|
NaN makes every tolerance comparison false, so a bad reading silently
|
||||||
|
comes out as "pass"; NaN and Infinity then both fail the DECIMAL(12,6)
|
||||||
|
column at insert time with an IntegrityError the operator never asked
|
||||||
|
for. Raised as a plain HTTPException with a string detail rather than as
|
||||||
|
a pydantic Field constraint on the request model: a Field rejecting NaN
|
||||||
|
(via allow_inf_nan or a gt/lt bound) makes pydantic echo that same NaN
|
||||||
|
back in the validation error's "input", and FastAPI's default error
|
||||||
|
response then crashes with a 500 trying to JSON-encode it
|
||||||
|
(Starlette's JSONResponse calls json.dumps(..., allow_nan=False)).
|
||||||
|
"""
|
||||||
|
if not math.isfinite(value):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Measured value must be a finite number (no NaN/Infinity)",
|
||||||
|
)
|
||||||
|
if abs(value) >= _MAX_ABS_VALUE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Measured value out of range: |value| must be < {_MAX_ABS_VALUE}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def calculate_pass_fail(
|
def calculate_pass_fail(
|
||||||
@@ -19,7 +61,12 @@ def calculate_pass_fail(
|
|||||||
- If value is outside UTL/LTL -> 'fail'
|
- If value is outside UTL/LTL -> 'fail'
|
||||||
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
|
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
|
||||||
- Otherwise -> 'pass'
|
- Otherwise -> 'pass'
|
||||||
|
|
||||||
|
The value is rounded to 6 decimals first - the precision the DECIMAL(12,6)
|
||||||
|
column keeps - so the verdict is computed on the same number that ends up
|
||||||
|
stored, and a value like 10.5000004 does not fail against a 10.5 limit.
|
||||||
"""
|
"""
|
||||||
|
value = round(value, 6)
|
||||||
deviation = None
|
deviation = None
|
||||||
if subtask.nominal is not None:
|
if subtask.nominal is not None:
|
||||||
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
|
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
|
||||||
@@ -39,6 +86,270 @@ def calculate_pass_fail(
|
|||||||
return "pass", deviation
|
return "pass", deviation
|
||||||
|
|
||||||
|
|
||||||
|
async def recipe_of_version(db: AsyncSession, version_id: int) -> Recipe | None:
|
||||||
|
"""The recipe a version belongs to - where the rules of measurement live."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Recipe)
|
||||||
|
.join(RecipeVersion, RecipeVersion.recipe_id == Recipe.id)
|
||||||
|
.where(RecipeVersion.id == version_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_recipe_rules(
|
||||||
|
recipe: Recipe | None,
|
||||||
|
lot_number: str | None,
|
||||||
|
serial_number: str | None,
|
||||||
|
input_method: str,
|
||||||
|
) -> None:
|
||||||
|
"""Refuse a measurement the recipe does not allow.
|
||||||
|
|
||||||
|
Checked here rather than only on the screen because a rule that lives in the
|
||||||
|
frontend is a suggestion: the keypad can be hidden and the same request still
|
||||||
|
sent. This is the one place every measurement passes through.
|
||||||
|
"""
|
||||||
|
if recipe is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if recipe.requires_lot and not (lot_number or "").strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="This recipe requires a lot number",
|
||||||
|
)
|
||||||
|
if recipe.requires_serial and not (serial_number or "").strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="This recipe requires a serial number",
|
||||||
|
)
|
||||||
|
if input_method == "manual" and not recipe.allow_manual_input:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="This recipe does not allow values typed by hand: use the caliper",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scope(query, version_id: int, measured_by: int, production_run_id: int | None):
|
||||||
|
"""Narrow a measurement query to the run in progress, or to this operator's work.
|
||||||
|
|
||||||
|
Inside a production the run is the boundary. Outside one - a recipe executed on
|
||||||
|
its own - the boundary is the version and the person doing it, so two operators
|
||||||
|
on the same recipe do not block each other.
|
||||||
|
"""
|
||||||
|
if production_run_id is not None:
|
||||||
|
return query.where(Measurement.production_run_id == production_run_id)
|
||||||
|
return query.where(
|
||||||
|
Measurement.version_id == version_id,
|
||||||
|
Measurement.measured_by == measured_by,
|
||||||
|
Measurement.production_run_id.is_(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def pending_authorisation(
|
||||||
|
db: AsyncSession,
|
||||||
|
version_id: int,
|
||||||
|
measured_by: int,
|
||||||
|
production_run_id: int | None = None,
|
||||||
|
) -> Measurement | None:
|
||||||
|
"""The out-of-tolerance measurement that is holding everything up, if any.
|
||||||
|
|
||||||
|
Only the *latest* reading of each quote counts. Measuring the same quote again
|
||||||
|
is allowed - the caliper slips, the piece is reseated - and a second reading
|
||||||
|
within tolerance releases the block. What is not allowed is moving on to the
|
||||||
|
next quote while the one in hand is out of tolerance and nobody has said so.
|
||||||
|
|
||||||
|
How many attempts that permits is point 4's business, not this one's. Every
|
||||||
|
reading stays in the record either way.
|
||||||
|
"""
|
||||||
|
latest_per_subtask = _scope(
|
||||||
|
select(func.max(Measurement.id)),
|
||||||
|
version_id, measured_by, production_run_id,
|
||||||
|
).group_by(Measurement.subtask_id)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Measurement)
|
||||||
|
.where(
|
||||||
|
Measurement.id.in_(latest_per_subtask),
|
||||||
|
Measurement.pass_fail == "fail",
|
||||||
|
Measurement.supervisor_id.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Measurement.id)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def task_progress(
|
||||||
|
db: AsyncSession,
|
||||||
|
version_id: int,
|
||||||
|
measured_by: int,
|
||||||
|
production_run_id: int | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""How far each measurement task of a version has got.
|
||||||
|
|
||||||
|
A task left half done is the thing the operator cannot see today: they walk
|
||||||
|
away from it, come back to the list, and it looks exactly like one never
|
||||||
|
started. Counting quotes against measurements is what tells them apart.
|
||||||
|
|
||||||
|
Documental tasks are not in here. A note cannot be half read, and marking one
|
||||||
|
incomplete for ever would be noise on the only screen that has to stay legible.
|
||||||
|
Which tasks count is read from the declared type, not from whether quotes happen
|
||||||
|
to have been entered yet - the same rule point 2 settled everywhere else.
|
||||||
|
"""
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(RecipeTask.id, RecipeSubtask.id)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id, isouter=True)
|
||||||
|
.where(
|
||||||
|
RecipeTask.version_id == version_id,
|
||||||
|
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
by_task: dict[int, set[int]] = {}
|
||||||
|
for task_id, subtask_id in rows:
|
||||||
|
quotes = by_task.setdefault(task_id, set())
|
||||||
|
if subtask_id is not None:
|
||||||
|
quotes.add(subtask_id)
|
||||||
|
|
||||||
|
measured = set((await db.execute(
|
||||||
|
_scope(
|
||||||
|
select(Measurement.subtask_id).distinct(),
|
||||||
|
version_id, measured_by, production_run_id,
|
||||||
|
)
|
||||||
|
)).scalars().all())
|
||||||
|
|
||||||
|
progress = []
|
||||||
|
for task_id, subtask_ids in by_task.items():
|
||||||
|
total = len(subtask_ids)
|
||||||
|
done = len(subtask_ids & measured)
|
||||||
|
if total == 0:
|
||||||
|
# Declared as a measurement but with no quotes on it yet: nothing to
|
||||||
|
# take, so nothing to report as missing either.
|
||||||
|
state = "empty"
|
||||||
|
elif done == total:
|
||||||
|
state = "complete"
|
||||||
|
elif done:
|
||||||
|
state = "partial"
|
||||||
|
else:
|
||||||
|
state = "none"
|
||||||
|
progress.append({
|
||||||
|
"task_id": task_id,
|
||||||
|
"quotes": total,
|
||||||
|
"measured": done,
|
||||||
|
"state": state,
|
||||||
|
})
|
||||||
|
progress.sort(key=lambda row: row["task_id"])
|
||||||
|
return progress
|
||||||
|
|
||||||
|
|
||||||
|
async def current_cycle_measurements(
|
||||||
|
db: AsyncSession, run_id: int, task_id: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The quotes of a task already measured in the cycle that is under way.
|
||||||
|
|
||||||
|
A reload, a timer redirect or an auto-logout throws away what the page held in
|
||||||
|
memory; the operator would measure the same quotes again and the duplicates
|
||||||
|
would reach the SPC statistics. The cycle needs no column of its own: it starts
|
||||||
|
at the latest event of the run that opens one for this task - the run start, a
|
||||||
|
cycle_completed, or a remeasure of this task.
|
||||||
|
|
||||||
|
If a task_measured of this task follows that boundary the task is already
|
||||||
|
closed in this cycle and there is nothing to restore. Where a quote was measured
|
||||||
|
more than once, the last reading stands.
|
||||||
|
"""
|
||||||
|
boundary = (await db.execute(
|
||||||
|
select(ProductionEvent.id, ProductionEvent.created_at)
|
||||||
|
.where(
|
||||||
|
ProductionEvent.run_id == run_id,
|
||||||
|
(ProductionEvent.event_type.in_(("start", "cycle_completed")))
|
||||||
|
| (
|
||||||
|
(ProductionEvent.event_type == "remeasure")
|
||||||
|
& (ProductionEvent.task_id == task_id)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(ProductionEvent.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)).first()
|
||||||
|
|
||||||
|
event_filter = [ProductionEvent.run_id == run_id]
|
||||||
|
measurement_filter = [Measurement.production_run_id == run_id]
|
||||||
|
if boundary is not None:
|
||||||
|
boundary_id, boundary_at = boundary
|
||||||
|
event_filter.append(ProductionEvent.id > boundary_id)
|
||||||
|
measurement_filter.append(Measurement.measured_at > boundary_at)
|
||||||
|
|
||||||
|
closed = (await db.execute(
|
||||||
|
select(ProductionEvent.id).where(
|
||||||
|
*event_filter,
|
||||||
|
ProductionEvent.event_type == "task_measured",
|
||||||
|
ProductionEvent.task_id == task_id,
|
||||||
|
).limit(1)
|
||||||
|
)).first()
|
||||||
|
if closed is not None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(Measurement)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id)
|
||||||
|
.where(*measurement_filter, RecipeSubtask.task_id == task_id)
|
||||||
|
.order_by(Measurement.measured_at, Measurement.id)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
|
latest: dict[int, Measurement] = {}
|
||||||
|
for m in rows:
|
||||||
|
latest[m.subtask_id] = m
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"subtask_id": m.subtask_id,
|
||||||
|
"value": float(m.value),
|
||||||
|
"pass_fail": m.pass_fail,
|
||||||
|
"measured_at": m.measured_at,
|
||||||
|
}
|
||||||
|
for m in sorted(latest.values(), key=lambda m: (m.measured_at, m.id))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_run_for_version(
|
||||||
|
db: AsyncSession, run_id: int, version_id: int,
|
||||||
|
) -> "ProductionRun":
|
||||||
|
"""The run a measurement is being filed against, checked for coherence.
|
||||||
|
|
||||||
|
Three things have to hold before a value goes in against a production: the
|
||||||
|
run has to exist, it has to belong to the same recipe as the version being
|
||||||
|
measured (a run started for another product must not collect another
|
||||||
|
product's history), and it has to be in a state that accepts measurements
|
||||||
|
(not closed, not paused). Shared with vision_service, which files camera
|
||||||
|
measurements against a run the same way a manual reading does.
|
||||||
|
"""
|
||||||
|
# Local import: production_service imports this module at module scope,
|
||||||
|
# so importing it back here at module scope would be circular.
|
||||||
|
from src.backend.services import production_service
|
||||||
|
|
||||||
|
run = await production_service.get_run(db, run_id) # 404 if missing
|
||||||
|
|
||||||
|
recipe = await recipe_of_version(db, version_id)
|
||||||
|
if recipe is None or run.recipe_id != recipe.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Production run belongs to a different recipe than this version",
|
||||||
|
)
|
||||||
|
|
||||||
|
production_service._require_measurable(run) # 409 if closed or paused
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def authorise_measurement(
|
||||||
|
db: AsyncSession, measurement: Measurement, supervisor: User,
|
||||||
|
) -> Measurement:
|
||||||
|
"""Let an out-of-tolerance value stand, on the supervisor's name."""
|
||||||
|
measurement.supervisor_id = supervisor.id
|
||||||
|
measurement.authorised_at = utcnow()
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(measurement)
|
||||||
|
return measurement
|
||||||
|
|
||||||
|
|
||||||
async def save_measurement(
|
async def save_measurement(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
subtask_id: int,
|
subtask_id: int,
|
||||||
@@ -49,8 +360,21 @@ async def save_measurement(
|
|||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
input_method: str = "manual",
|
input_method: str = "manual",
|
||||||
input_duration_ms: int | None = None,
|
input_duration_ms: int | None = None,
|
||||||
|
production_run_id: int | None = None,
|
||||||
|
vision_result_id: int | None = None,
|
||||||
|
enforce_tolerance_gate: bool = True,
|
||||||
) -> Measurement:
|
) -> Measurement:
|
||||||
"""Save a single measurement with auto-calculated pass/fail."""
|
"""Save a single measurement with auto-calculated pass/fail.
|
||||||
|
|
||||||
|
``enforce_tolerance_gate=False`` skips only the pending-authorisation check
|
||||||
|
below - everything else (verdict, recipe rules, deviation) still applies.
|
||||||
|
This is for a caller that has already evaluated the gate itself for a whole
|
||||||
|
batch of quotes taken at the same instant (see Ruling R10 / vision_service):
|
||||||
|
the gate models "moving on" between measurements taken one after another,
|
||||||
|
which does not exist inside a single camera acquisition.
|
||||||
|
"""
|
||||||
|
_validate_measured_value(value)
|
||||||
|
|
||||||
# Get subtask for tolerance values
|
# Get subtask for tolerance values
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||||
@@ -59,6 +383,45 @@ async def save_measurement(
|
|||||||
if subtask is None:
|
if subtask is None:
|
||||||
raise ValueError(f"Subtask {subtask_id} not found")
|
raise ValueError(f"Subtask {subtask_id} not found")
|
||||||
|
|
||||||
|
# The quote has to belong to a task of the declared version - otherwise the
|
||||||
|
# tolerances just read off `subtask` are not the ones this version's operator
|
||||||
|
# was shown, and pass/fail is computed against the wrong recipe.
|
||||||
|
task_row = await db.execute(
|
||||||
|
select(RecipeTask.version_id).where(RecipeTask.id == subtask.task_id)
|
||||||
|
)
|
||||||
|
subtask_version_id = task_row.scalar_one_or_none()
|
||||||
|
if subtask_version_id != version_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Subtask does not belong to the given version",
|
||||||
|
)
|
||||||
|
|
||||||
|
if production_run_id is not None:
|
||||||
|
await validate_run_for_version(db, production_run_id, version_id)
|
||||||
|
|
||||||
|
recipe = await recipe_of_version(db, version_id)
|
||||||
|
_enforce_recipe_rules(recipe, lot_number, serial_number, input_method)
|
||||||
|
|
||||||
|
# No moving on while a quote is out of tolerance and unauthorised. Measuring
|
||||||
|
# that same quote again is the way out that does not need the capoturno; the
|
||||||
|
# next quote is not.
|
||||||
|
if enforce_tolerance_gate:
|
||||||
|
blocking = await pending_authorisation(
|
||||||
|
db, version_id, measured_by, production_run_id,
|
||||||
|
)
|
||||||
|
if blocking is not None and blocking.subtask_id != subtask_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||||
|
"authorise it or measure that quote again before going on"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rounded once, here, so the stored value is exactly the one the verdict
|
||||||
|
# above was computed on - calculate_pass_fail rounds internally too, but
|
||||||
|
# matching it here keeps the persisted row from disagreeing with itself.
|
||||||
|
value = round(value, 6)
|
||||||
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
pass_fail, deviation = calculate_pass_fail(value, subtask)
|
||||||
|
|
||||||
measurement = Measurement(
|
measurement = Measurement(
|
||||||
@@ -72,6 +435,8 @@ async def save_measurement(
|
|||||||
serial_number=serial_number,
|
serial_number=serial_number,
|
||||||
input_method=input_method,
|
input_method=input_method,
|
||||||
input_duration_ms=input_duration_ms,
|
input_duration_ms=input_duration_ms,
|
||||||
|
production_run_id=production_run_id,
|
||||||
|
vision_result_id=vision_result_id,
|
||||||
)
|
)
|
||||||
db.add(measurement)
|
db.add(measurement)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Live monitor of the open production runs (#57).
|
||||||
|
|
||||||
|
One query per table, whatever the number of runs: measurements and last events are
|
||||||
|
ranked per run with a window function and cut in the database, not in a loop.
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.api.monitor import MonitorEvent, MonitorRun, MonitorSubtask
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import production_service
|
||||||
|
|
||||||
|
# Recent measurements per run that feed counts and means (#57: last N).
|
||||||
|
MONITOR_WINDOW = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _in_tolerance(mean: float, ltl, utl) -> bool:
|
||||||
|
# A missing limit does not constrain.
|
||||||
|
if ltl is not None and mean < float(ltl):
|
||||||
|
return False
|
||||||
|
if utl is not None and mean > float(utl):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
|
||||||
|
runs = (await db.execute(
|
||||||
|
select(ProductionRun)
|
||||||
|
.where(ProductionRun.status.in_(("running", "paused")))
|
||||||
|
.order_by(ProductionRun.started_at)
|
||||||
|
)).scalars().all()
|
||||||
|
if not runs:
|
||||||
|
return []
|
||||||
|
run_ids = [r.id for r in runs]
|
||||||
|
|
||||||
|
# Operators: one query.
|
||||||
|
users = {
|
||||||
|
u.id: u for u in (await db.execute(
|
||||||
|
select(User).where(User.id.in_({r.operator_id for r in runs}))
|
||||||
|
)).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Last event per run: rank desc inside the run, keep rank 1.
|
||||||
|
ev_rank = func.row_number().over(
|
||||||
|
partition_by=ProductionEvent.run_id,
|
||||||
|
order_by=(ProductionEvent.created_at.desc(), ProductionEvent.id.desc()),
|
||||||
|
).label("rn")
|
||||||
|
ev_sub = select(
|
||||||
|
ProductionEvent.run_id.label("run_id"),
|
||||||
|
ProductionEvent.event_type.label("event_type"),
|
||||||
|
ProductionEvent.created_at.label("created_at"),
|
||||||
|
ProductionEvent.note.label("note"),
|
||||||
|
ev_rank,
|
||||||
|
).where(ProductionEvent.run_id.in_(run_ids)).subquery()
|
||||||
|
ev_rows = (await db.execute(
|
||||||
|
select(ev_sub.c.run_id, ev_sub.c.event_type, ev_sub.c.created_at, ev_sub.c.note)
|
||||||
|
.where(ev_sub.c.rn == 1)
|
||||||
|
)).all()
|
||||||
|
last_event = {r.run_id: r for r in ev_rows}
|
||||||
|
|
||||||
|
# Last N measurements per run, joined to their subtask for the limits.
|
||||||
|
m_rank = func.row_number().over(
|
||||||
|
partition_by=Measurement.production_run_id,
|
||||||
|
order_by=(Measurement.measured_at.desc(), Measurement.id.desc()),
|
||||||
|
).label("rn")
|
||||||
|
m_sub = select(
|
||||||
|
Measurement.production_run_id.label("run_id"),
|
||||||
|
Measurement.subtask_id.label("subtask_id"),
|
||||||
|
Measurement.value.label("value"),
|
||||||
|
Measurement.pass_fail.label("pass_fail"),
|
||||||
|
m_rank,
|
||||||
|
).where(Measurement.production_run_id.in_(run_ids)).subquery()
|
||||||
|
m_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
m_sub.c.run_id, m_sub.c.subtask_id, m_sub.c.value, m_sub.c.pass_fail,
|
||||||
|
RecipeSubtask.description, RecipeSubtask.unit,
|
||||||
|
RecipeSubtask.ltl, RecipeSubtask.utl,
|
||||||
|
)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.id == m_sub.c.subtask_id)
|
||||||
|
.where(m_sub.c.rn <= MONITOR_WINDOW)
|
||||||
|
)).all()
|
||||||
|
by_run = defaultdict(list)
|
||||||
|
for row in m_rows:
|
||||||
|
by_run[row.run_id].append(row)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for run in runs:
|
||||||
|
rows = by_run.get(run.id, [])
|
||||||
|
per_sub: dict[int, list] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
per_sub[row.subtask_id].append(row)
|
||||||
|
subtasks = []
|
||||||
|
for sid, group in sorted(per_sub.items()):
|
||||||
|
mean = sum(float(g.value) for g in group) / len(group)
|
||||||
|
first = group[0]
|
||||||
|
subtasks.append(MonitorSubtask(
|
||||||
|
subtask_id=sid, description=first.description, unit=first.unit,
|
||||||
|
count=len(group), mean=round(mean, 6),
|
||||||
|
ltl=float(first.ltl) if first.ltl is not None else None,
|
||||||
|
utl=float(first.utl) if first.utl is not None else None,
|
||||||
|
in_tolerance=_in_tolerance(mean, first.ltl, first.utl),
|
||||||
|
))
|
||||||
|
timer = production_service.describe(run)
|
||||||
|
ev = last_event.get(run.id)
|
||||||
|
operator = users.get(run.operator_id)
|
||||||
|
result.append(MonitorRun(
|
||||||
|
id=run.id,
|
||||||
|
station_code=run.station.code, station_name=run.station.name,
|
||||||
|
recipe_code=run.recipe.code, recipe_name=run.recipe.name,
|
||||||
|
version_number=run.version.version_number,
|
||||||
|
operator=(operator.display_name or operator.username) if operator else "",
|
||||||
|
status=run.status,
|
||||||
|
started_at=run.started_at,
|
||||||
|
duration_seconds=max(0, int((timer["server_time"] - run.started_at).total_seconds())),
|
||||||
|
cycle_count=run.cycle_count,
|
||||||
|
seconds_to_next_measurement=timer["seconds_to_next_measurement"],
|
||||||
|
overdue=timer["overdue"],
|
||||||
|
last_event=MonitorEvent(
|
||||||
|
event_type=ev.event_type, created_at=ev.created_at, note=ev.note,
|
||||||
|
) if ev else None,
|
||||||
|
measured=len(rows),
|
||||||
|
pass_count=sum(1 for g in rows if g.pass_fail == "pass"),
|
||||||
|
warning_count=sum(1 for g in rows if g.pass_fail == "warning"),
|
||||||
|
fail_count=sum(1 for g in rows if g.pass_fail == "fail"),
|
||||||
|
subtasks=subtasks,
|
||||||
|
server_time=timer["server_time"],
|
||||||
|
))
|
||||||
|
return result
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Statistics file emitted when a production is closed.
|
||||||
|
|
||||||
|
Fine produzione has to hand the measurements of the whole production over. That part
|
||||||
|
does not depend on the ERP: the file is produced now, and the hand-off to GAIA plugs
|
||||||
|
in beside it once the protocol is agreed (questions D-1 and D-2). Keeping it in its
|
||||||
|
own module makes that seam obvious rather than buried in the close path.
|
||||||
|
|
||||||
|
The CSV uses the delimiters configured in system_settings, the same ones the manual
|
||||||
|
export honours, so a shop floor set to the Italian convention (';' and ',') gets
|
||||||
|
files that open correctly in their Excel.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from src.backend.utils.tempo import iso_utc, to_local, utcnow
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
from src.backend.models.orm.recipe import Recipe
|
||||||
|
from src.backend.models.orm.setting import SystemSetting
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Written under the upload directory: it is the volume that is already mounted and
|
||||||
|
# backed up, so the files survive a container being replaced.
|
||||||
|
EXPORT_SUBDIR = "statistics"
|
||||||
|
|
||||||
|
# `csv.writer` requires a 1-character delimiter. PUT /api/settings/ validates this
|
||||||
|
# going forward (gitea#23), but a value written before that guard existed must not
|
||||||
|
# be able to make close_run fail: fall back and log instead of raising.
|
||||||
|
_VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
|
||||||
|
_FALLBACK_CSV_DELIMITER = ";"
|
||||||
|
|
||||||
|
# A cell starting with one of these (after leading spaces) is read as a formula
|
||||||
|
# by Excel/LibreOffice when the CSV is opened — CSV/formula injection (OWASP).
|
||||||
|
_DANGEROUS_CSV_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
||||||
|
|
||||||
|
|
||||||
|
def neutralise_csv_cell(value: str) -> str:
|
||||||
|
"""Defuse a formula-injection payload in a text CSV cell.
|
||||||
|
|
||||||
|
Prepends a single quote when the value, stripped of leading spaces, starts
|
||||||
|
with =, +, -, @, TAB or CR: this keeps spreadsheet software from evaluating
|
||||||
|
it as a formula while the value stays valid, literal CSV text. Only for
|
||||||
|
free-text fields (lot, serial, descriptions, names, codes) — never call
|
||||||
|
this on numeric cells, the quote would make them text.
|
||||||
|
"""
|
||||||
|
stripped = value.lstrip(" ")
|
||||||
|
if stripped and stripped[0] in _DANGEROUS_CSV_PREFIXES:
|
||||||
|
return "'" + value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
HEADER = [
|
||||||
|
"production_run_id",
|
||||||
|
"station_code",
|
||||||
|
"recipe_code",
|
||||||
|
"version_id",
|
||||||
|
"lot_number",
|
||||||
|
"serial_number",
|
||||||
|
"measurement_id",
|
||||||
|
"subtask_id",
|
||||||
|
"marker_number",
|
||||||
|
"subtask_description",
|
||||||
|
"nominal",
|
||||||
|
"value",
|
||||||
|
"deviation",
|
||||||
|
"pass_fail",
|
||||||
|
# The limits travel with the row: without them a pass/fail cannot be re-derived
|
||||||
|
# from the file years later, which is the whole point of an audit record.
|
||||||
|
"ltl",
|
||||||
|
"lwl",
|
||||||
|
"uwl",
|
||||||
|
"utl",
|
||||||
|
"unit",
|
||||||
|
"input_method",
|
||||||
|
"input_duration_ms",
|
||||||
|
"measured_by",
|
||||||
|
"measured_at",
|
||||||
|
# A value outside tolerance that was allowed to stand carries the name of who
|
||||||
|
# allowed it. Without these two columns the file would show the failure and
|
||||||
|
# not the decision, which is the half an auditor asks about.
|
||||||
|
"authorised_by",
|
||||||
|
"authorised_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _csv_format(db: AsyncSession) -> tuple[str, str]:
|
||||||
|
"""Delimiter and decimal separator as configured for this installation."""
|
||||||
|
delimiter_row = await db.execute(
|
||||||
|
select(SystemSetting).where(SystemSetting.setting_key == "csv_delimiter")
|
||||||
|
)
|
||||||
|
delimiter = delimiter_row.scalar_one_or_none()
|
||||||
|
decimal_row = await db.execute(
|
||||||
|
select(SystemSetting).where(
|
||||||
|
SystemSetting.setting_key == "csv_decimal_separator"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
decimal = decimal_row.scalar_one_or_none()
|
||||||
|
|
||||||
|
delimiter_value = delimiter.setting_value if delimiter else ","
|
||||||
|
if delimiter_value not in _VALID_CSV_DELIMITERS:
|
||||||
|
logger.warning(
|
||||||
|
"csv_delimiter non valido nel DB (%r): uso %r",
|
||||||
|
delimiter_value, _FALLBACK_CSV_DELIMITER,
|
||||||
|
)
|
||||||
|
delimiter_value = _FALLBACK_CSV_DELIMITER
|
||||||
|
|
||||||
|
return (
|
||||||
|
delimiter_value,
|
||||||
|
decimal.setting_value if decimal else ".",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
||||||
|
"""Render every measurement of a run as CSV text."""
|
||||||
|
delimiter, decimal_separator = await _csv_format(db)
|
||||||
|
|
||||||
|
station = (await db.execute(
|
||||||
|
select(Station).where(Station.id == run.station_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
recipe = (await db.execute(
|
||||||
|
select(Recipe).where(Recipe.id == run.recipe_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(Measurement, RecipeSubtask)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id, isouter=True)
|
||||||
|
.where(Measurement.production_run_id == run.id)
|
||||||
|
.order_by(Measurement.measured_at, Measurement.id)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
def number(value) -> str:
|
||||||
|
return "" if value is None else str(value).replace(".", decimal_separator)
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output, delimiter=delimiter, lineterminator="\n")
|
||||||
|
writer.writerow(HEADER)
|
||||||
|
|
||||||
|
for measurement, subtask in rows:
|
||||||
|
writer.writerow([
|
||||||
|
run.id,
|
||||||
|
neutralise_csv_cell(station.code) if station else "",
|
||||||
|
neutralise_csv_cell(recipe.code) if recipe else "",
|
||||||
|
measurement.version_id,
|
||||||
|
neutralise_csv_cell(measurement.lot_number or ""),
|
||||||
|
neutralise_csv_cell(measurement.serial_number or ""),
|
||||||
|
measurement.id,
|
||||||
|
measurement.subtask_id,
|
||||||
|
subtask.marker_number if subtask else "",
|
||||||
|
neutralise_csv_cell(subtask.description) if subtask else "",
|
||||||
|
number(subtask.nominal) if subtask else "",
|
||||||
|
number(measurement.value),
|
||||||
|
number(measurement.deviation),
|
||||||
|
measurement.pass_fail,
|
||||||
|
number(subtask.ltl) if subtask else "",
|
||||||
|
number(subtask.lwl) if subtask else "",
|
||||||
|
number(subtask.uwl) if subtask else "",
|
||||||
|
number(subtask.utl) if subtask else "",
|
||||||
|
(subtask.unit or "") if subtask else "",
|
||||||
|
measurement.input_method,
|
||||||
|
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
||||||
|
measurement.measured_by,
|
||||||
|
iso_utc(measurement.measured_at),
|
||||||
|
measurement.supervisor_id or "",
|
||||||
|
iso_utc(measurement.authorised_at),
|
||||||
|
])
|
||||||
|
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | None:
|
||||||
|
"""Write the run's statistics file and mark its measurements as exported.
|
||||||
|
|
||||||
|
Returns the path relative to the upload directory, or None when the run has no
|
||||||
|
measurements - an empty file would only be noise in the folder.
|
||||||
|
"""
|
||||||
|
content = await build_statistics_csv(db, run)
|
||||||
|
# Header only: nothing was measured during this production.
|
||||||
|
if len(content.strip().splitlines()) <= 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
directory = Path(settings.upload_path) / EXPORT_SUBDIR
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
stamp = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"production_{run.id}_{stamp}.csv"
|
||||||
|
# A large run's file can take a moment to write: off the event loop, so
|
||||||
|
# closing one run does not stall every other request being served.
|
||||||
|
await asyncio.to_thread(
|
||||||
|
(directory / filename).write_text, content, encoding="utf-8-sig"
|
||||||
|
)
|
||||||
|
|
||||||
|
# utf-8-sig: the BOM is what makes Excel read accented characters correctly,
|
||||||
|
# and these files are opened in Excel on the shop floor.
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
update(Measurement)
|
||||||
|
.where(Measurement.production_run_id == run.id)
|
||||||
|
.values(synced_to_csv=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"{EXPORT_SUBDIR}/{filename}"
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
"""Business logic for production runs.
|
||||||
|
|
||||||
|
Routers must call into these functions rather than manipulating models directly.
|
||||||
|
All functions are async and accept an AsyncSession; they flush but do NOT commit
|
||||||
|
(commit is handled by the FastAPI get_db dependency).
|
||||||
|
|
||||||
|
Clock convention
|
||||||
|
----------------
|
||||||
|
Every timestamp this module writes comes from ``_now()``, so the deadline and the
|
||||||
|
comparison against it are always on the same clock. Clients are handed
|
||||||
|
``seconds_to_next_measurement`` already computed rather than the raw deadline: a
|
||||||
|
naive datetime crossing the wire would be parsed in the browser's own timezone, and
|
||||||
|
the countdown would be off by the UTC offset. The number is the contract; the
|
||||||
|
timestamp is only there for the audit trail.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.api.production import ProductionRunCreate
|
||||||
|
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||||
|
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
|
from src.backend.models.orm.task import MEASURING_TASK_TYPES, RecipeTask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import (
|
||||||
|
auth_service, measurement_service, production_export_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
"""Single source of time for runs.
|
||||||
|
|
||||||
|
Naive UTC, matching what the database writes for ``func.now()`` defaults,
|
||||||
|
so a run's started_at and its deadline are comparable.
|
||||||
|
"""
|
||||||
|
return utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Derived view
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def seconds_to_next_measurement(run: ProductionRun, now: Optional[datetime] = None) -> Optional[int]:
|
||||||
|
"""Seconds until the next measurement is due; negative once it is overdue.
|
||||||
|
|
||||||
|
Letting the value go negative is deliberate: the operator has to see how long
|
||||||
|
the line has been past the interval, not merely that it elapsed.
|
||||||
|
While the run is paused the countdown is frozen at what was left when the line
|
||||||
|
stopped - a fermo linea must not eat into the measurement interval.
|
||||||
|
"""
|
||||||
|
if run.next_measurement_at is None:
|
||||||
|
return None
|
||||||
|
reference = run.paused_at if (run.status == "paused" and run.paused_at) else (now or _now())
|
||||||
|
return int(round((run.next_measurement_at - reference).total_seconds()))
|
||||||
|
|
||||||
|
|
||||||
|
def describe(run: ProductionRun) -> dict:
|
||||||
|
"""Fields the API adds on top of the stored columns."""
|
||||||
|
now = _now()
|
||||||
|
remaining = seconds_to_next_measurement(run, now)
|
||||||
|
return {
|
||||||
|
"seconds_to_next_measurement": remaining,
|
||||||
|
"overdue": remaining is not None and remaining < 0,
|
||||||
|
"server_time": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lookups
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def get_run(db: AsyncSession, run_id: int) -> ProductionRun:
|
||||||
|
result = await db.execute(select(ProductionRun).where(ProductionRun.id == run_id))
|
||||||
|
run = result.scalar_one_or_none()
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found",
|
||||||
|
)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def get_open_run_for_station(
|
||||||
|
db: AsyncSession, station_id: int,
|
||||||
|
) -> Optional[ProductionRun]:
|
||||||
|
"""The run currently open at a station, running or paused."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProductionRun).where(ProductionRun.active_station_id == station_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_station_by_code(db: AsyncSession, code: str) -> Station:
|
||||||
|
result = await db.execute(select(Station).where(Station.code == code))
|
||||||
|
station = result.scalar_one_or_none()
|
||||||
|
if station is None or not station.active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Station '{code}' not found or inactive",
|
||||||
|
)
|
||||||
|
return station
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_recipe_assigned_to_station(
|
||||||
|
db: AsyncSession, station_id: int, recipe_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""A configured station only runs what it is assigned; an unconfigured one is free.
|
||||||
|
|
||||||
|
Mirrors station_service.list_station_recipes, the read side an operator's
|
||||||
|
recipe list is already filtered by: a station with at least one assignment
|
||||||
|
restricts to those recipes, one with none has not been set up to filter at
|
||||||
|
all and stays open to any active recipe.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(StationRecipeAssignment.recipe_id).where(
|
||||||
|
StationRecipeAssignment.station_id == station_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assigned_ids = set(result.scalars().all())
|
||||||
|
if assigned_ids and recipe_id not in assigned_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="This recipe is not assigned to this station",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||||
|
"""Ids of the measurement tasks of a version, in the order they are executed.
|
||||||
|
|
||||||
|
This is the loop the operator lives in once production starts: the client uses
|
||||||
|
the first of these to bring the measurement back on screen when the interval
|
||||||
|
expires, and the last to know which task closes a cycle.
|
||||||
|
|
||||||
|
The type is read from the declared task_type (point 2), not from whether quotes
|
||||||
|
happen to be present: a measurement task with no quotes yet is still where the
|
||||||
|
operator has to be taken.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeTask.id)
|
||||||
|
.where(
|
||||||
|
RecipeTask.version_id == version_id,
|
||||||
|
# #53: il ciclo lo dichiara il task (in_loop), non piu' il solo tipo.
|
||||||
|
RecipeTask.in_loop.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def close_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||||
|
"""Ids dei task da compilare alla fine produzione (at_close), in ordine (#53)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeTask.id)
|
||||||
|
.where(
|
||||||
|
RecipeTask.version_id == version_id,
|
||||||
|
RecipeTask.at_close.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def list_run_events(db: AsyncSession, run_id: int) -> list[ProductionEvent]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProductionEvent)
|
||||||
|
.where(ProductionEvent.run_id == run_id)
|
||||||
|
.order_by(ProductionEvent.created_at, ProductionEvent.id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Supervisor authorisation
|
||||||
|
# The capoturno check lives in auth_service, next to the rest of the credential
|
||||||
|
# handling: a line stop, a closure and an out-of-tolerance value all ask the same
|
||||||
|
# question, and only one of the three is about production.
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _add_event(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
event_type: str,
|
||||||
|
user: User,
|
||||||
|
supervisor: Optional[User] = None,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
task_id: Optional[int] = None,
|
||||||
|
) -> ProductionEvent:
|
||||||
|
"""Append to the run's trace.
|
||||||
|
|
||||||
|
Added through the session rather than run.events: touching the collection would
|
||||||
|
trigger a lazy load, which raises under async SQLAlchemy.
|
||||||
|
"""
|
||||||
|
event = ProductionEvent(
|
||||||
|
run_id=run.id,
|
||||||
|
event_type=event_type,
|
||||||
|
user_id=user.id,
|
||||||
|
supervisor_id=supervisor.id if supervisor else None,
|
||||||
|
note=note,
|
||||||
|
task_id=task_id,
|
||||||
|
created_at=_now(),
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def open_run(
|
||||||
|
db: AsyncSession, data: ProductionRunCreate, operator: User,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Start a production at a station.
|
||||||
|
|
||||||
|
Refuses if the station already has one open: two runs on the same station would
|
||||||
|
mean two timers and two histories for one physical line.
|
||||||
|
"""
|
||||||
|
station = await get_station_by_code(db, data.station_code)
|
||||||
|
|
||||||
|
existing = await get_open_run_for_station(db, station.id)
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Station '{station.code}' already has an open production run",
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe_row = await db.execute(select(Recipe).where(Recipe.id == data.recipe_id))
|
||||||
|
recipe = recipe_row.scalar_one_or_none()
|
||||||
|
if recipe is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found",
|
||||||
|
)
|
||||||
|
if not recipe.active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Cannot start a production run with an inactive recipe",
|
||||||
|
)
|
||||||
|
await _require_recipe_assigned_to_station(db, station.id, recipe.id)
|
||||||
|
|
||||||
|
if data.version_id is not None:
|
||||||
|
version_row = await db.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.id == data.version_id,
|
||||||
|
RecipeVersion.recipe_id == recipe.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
version = version_row.scalar_one_or_none()
|
||||||
|
if version is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Recipe version not found for this recipe",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
version_row = await db.execute(
|
||||||
|
select(RecipeVersion).where(
|
||||||
|
RecipeVersion.recipe_id == recipe.id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
version = version_row.scalar_one_or_none()
|
||||||
|
if version is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Recipe has no current version",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Traceability the recipe declares compulsory has to be there before the first
|
||||||
|
# measurement, not after it: a lot entered halfway through leaves the values
|
||||||
|
# taken up to that point unattributable.
|
||||||
|
if recipe.requires_lot and not (data.lot_number or "").strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="This recipe requires a lot number to start a production",
|
||||||
|
)
|
||||||
|
if recipe.requires_serial and not (data.serial_number or "").strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="This recipe requires a serial number to start a production",
|
||||||
|
)
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
# The interval is copied, not referenced: editing the recipe mid-production must
|
||||||
|
# not move the deadline of a run already under way.
|
||||||
|
interval = recipe.measurement_interval_minutes
|
||||||
|
run = ProductionRun(
|
||||||
|
station_id=station.id,
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
version_id=version.id,
|
||||||
|
operator_id=operator.id,
|
||||||
|
lot_number=data.lot_number,
|
||||||
|
serial_number=data.serial_number,
|
||||||
|
status="running",
|
||||||
|
measurement_interval_minutes=interval,
|
||||||
|
next_measurement_at=(
|
||||||
|
now + timedelta(minutes=interval) if interval and interval > 0 else None
|
||||||
|
),
|
||||||
|
cycle_count=0,
|
||||||
|
started_at=now,
|
||||||
|
active_station_id=station.id,
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.flush()
|
||||||
|
_add_event(db, run, "start", operator)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _require_open(run: ProductionRun) -> None:
|
||||||
|
if run.status == "closed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Production run is already closed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_measurable(run: ProductionRun) -> None:
|
||||||
|
_require_open(run)
|
||||||
|
if run.status == "paused":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Production run is paused: resume it before measuring",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def complete_cycle(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
user: User,
|
||||||
|
task_id: Optional[int] = None,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Record a finished measurement task, and restart the interval if it was the last.
|
||||||
|
|
||||||
|
A cycle spans every measurement task of the recipe, so only the last one closes
|
||||||
|
it. Restarting the interval at the first would make a recipe with three
|
||||||
|
measurement tasks measure three times as often as it was set up to.
|
||||||
|
|
||||||
|
An unrecognised task_id - or none at all - closes the cycle: a caller that does
|
||||||
|
not know about the sequence gets the behaviour that existed before it.
|
||||||
|
"""
|
||||||
|
_require_measurable(run)
|
||||||
|
|
||||||
|
# A cycle that contains a quote out of tolerance is not a finished cycle. This
|
||||||
|
# is the same gate as on the next measurement: closing the cycle would be
|
||||||
|
# another way of walking past it.
|
||||||
|
blocking = await measurement_service.pending_authorisation(
|
||||||
|
db, run.version_id, user.id, run.id,
|
||||||
|
)
|
||||||
|
if blocking is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||||
|
"the cycle cannot be closed until it is authorised"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
measure_ids = await measurement_task_ids(db, run.version_id)
|
||||||
|
closes_cycle = (
|
||||||
|
task_id is None or task_id not in measure_ids or task_id == measure_ids[-1]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not closes_cycle:
|
||||||
|
_add_event(db, run, "task_measured", user, note=note, task_id=task_id)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
run.cycle_count += 1
|
||||||
|
if run.measurement_interval_minutes and run.measurement_interval_minutes > 0:
|
||||||
|
# Measured from now, not from the previous deadline: a late measurement must
|
||||||
|
# not compress the following interval.
|
||||||
|
run.next_measurement_at = now + timedelta(
|
||||||
|
minutes=run.measurement_interval_minutes
|
||||||
|
)
|
||||||
|
_add_event(db, run, "cycle_completed", user, note=note, task_id=task_id)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def remeasure(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
user: User,
|
||||||
|
task_id: Optional[int] = None,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Measure the same task again without closing the cycle.
|
||||||
|
|
||||||
|
This is the piece being turned over. The deadline and the cycle count are left
|
||||||
|
exactly as they are - a second reading of the same part is not a new cycle, and
|
||||||
|
it must not buy the operator another interval's worth of time. The values
|
||||||
|
themselves are ordinary measurements: both readings stay in the statistics,
|
||||||
|
which is the point of taking them.
|
||||||
|
"""
|
||||||
|
_require_measurable(run)
|
||||||
|
_add_event(db, run, "remeasure", user, note=note, task_id=task_id)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def pause_run(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
user: User,
|
||||||
|
supervisor: User,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Fermo linea: suspend the run and freeze the countdown."""
|
||||||
|
_require_open(run)
|
||||||
|
if run.status == "paused":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Production run is already paused",
|
||||||
|
)
|
||||||
|
run.status = "paused"
|
||||||
|
run.paused_at = _now()
|
||||||
|
_add_event(db, run, "line_stop", user, supervisor=supervisor, note=note)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def resume_run(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
user: User,
|
||||||
|
supervisor: User,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Restart a paused run, pushing the deadline out by the length of the stop."""
|
||||||
|
_require_open(run)
|
||||||
|
if run.status != "paused":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT, detail="Production run is not paused",
|
||||||
|
)
|
||||||
|
now = _now()
|
||||||
|
if run.next_measurement_at is not None and run.paused_at is not None:
|
||||||
|
# Shift rather than recompute: whatever was left when the line stopped is
|
||||||
|
# what the operator gets back, so a stop neither grants nor costs time.
|
||||||
|
run.next_measurement_at = run.next_measurement_at + (now - run.paused_at)
|
||||||
|
run.status = "running"
|
||||||
|
run.paused_at = None
|
||||||
|
_add_event(db, run, "resume", user, supervisor=supervisor, note=note)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def close_run(
|
||||||
|
db: AsyncSession,
|
||||||
|
run: ProductionRun,
|
||||||
|
user: User,
|
||||||
|
supervisor: User,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> ProductionRun:
|
||||||
|
"""Fine produzione: close the run for good, stop the timer, emit the statistics.
|
||||||
|
|
||||||
|
Everything here happens without the ERP. Handing the production over to GAIA
|
||||||
|
goes at the marked seam below, once D-1 and D-2 are answered; until then the
|
||||||
|
file is produced and the run is properly closed, which is what the shop floor
|
||||||
|
needs either way.
|
||||||
|
"""
|
||||||
|
_require_open(run)
|
||||||
|
run.status = "closed"
|
||||||
|
run.closed_at = _now()
|
||||||
|
run.closed_by = supervisor.id
|
||||||
|
run.next_measurement_at = None
|
||||||
|
run.paused_at = None
|
||||||
|
# Releasing the slot lets the station open a new run; the unique index on this
|
||||||
|
# column is what keeps exactly one open at a time.
|
||||||
|
run.active_station_id = None
|
||||||
|
_add_event(db, run, "close", user, supervisor=supervisor, note=note)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Statistics file for the whole production, and the measurements marked as sent.
|
||||||
|
run.statistics_path = await production_export_service.export_run_statistics(db, run)
|
||||||
|
|
||||||
|
# --- ERP hand-off (GAIA) plugs in here once the protocol is defined (D-1, D-2).
|
||||||
|
# Deliberately absent rather than stubbed: an empty call that looks wired is
|
||||||
|
# worse than none at all.
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(run)
|
||||||
|
return run
|
||||||
@@ -12,6 +12,7 @@ from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
|||||||
from src.backend.models.orm.setting import RecipeVersionAudit
|
from src.backend.models.orm.setting import RecipeVersionAudit
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage
|
||||||
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +20,51 @@ from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Columns of the recipe header itself, as opposed to the versioned content. Both
|
||||||
|
# update paths - copy-on-write and in-place - write exactly these, so the list
|
||||||
|
# lives once: a field added to one and forgotten in the other would appear to save
|
||||||
|
# and then quietly not, depending on whether the version had measurements.
|
||||||
|
HEADER_FIELDS = (
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"image_path",
|
||||||
|
"measurement_interval_minutes",
|
||||||
|
"requires_lot",
|
||||||
|
"requires_serial",
|
||||||
|
"allow_manual_input",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# image_path and measurement_interval_minutes are nullable columns where the
|
||||||
|
# caller may deliberately want to clear the value (send null), not merely
|
||||||
|
# leave it untouched by not mentioning the field at all. A plain
|
||||||
|
# "getattr(...) is not None" check cannot tell those two cases apart -
|
||||||
|
# once set, neither field could ever be cleared again (#37.3). The other
|
||||||
|
# header fields keep that simpler check: booleans are always sent by the
|
||||||
|
# client (never null) and clearing name/description to null is not a case
|
||||||
|
# this endpoint needs to support.
|
||||||
|
_NULLABLE_HEADER_FIELDS = ("image_path", "measurement_interval_minutes")
|
||||||
|
|
||||||
|
|
||||||
|
def _header_fields(data: RecipeUpdate) -> dict:
|
||||||
|
"""The header columns the caller actually asked to change.
|
||||||
|
|
||||||
|
For image_path/measurement_interval_minutes, "asked to change" includes
|
||||||
|
explicitly setting it to null - told apart from "field absent from the
|
||||||
|
request" via model_fields_set, since RecipeUpdate defaults both to None.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for field in HEADER_FIELDS:
|
||||||
|
if field in _NULLABLE_HEADER_FIELDS:
|
||||||
|
if field in data.model_fields_set:
|
||||||
|
result[field] = getattr(data, field)
|
||||||
|
else:
|
||||||
|
value = getattr(data, field)
|
||||||
|
if value is not None:
|
||||||
|
result[field] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
|
async def _get_recipe_or_404(db: AsyncSession, recipe_id: int) -> Recipe:
|
||||||
"""Return a recipe or raise 404."""
|
"""Return a recipe or raise 404."""
|
||||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||||
@@ -55,16 +101,52 @@ async def _copy_tasks_to_version(
|
|||||||
new_task = RecipeTask(
|
new_task = RecipeTask(
|
||||||
version_id=target_version.id,
|
version_id=target_version.id,
|
||||||
order_index=task.order_index,
|
order_index=task.order_index,
|
||||||
|
# Carried over explicitly: a copy-on-write version that dropped the type
|
||||||
|
# would silently reclassify every task the moment a recipe is edited.
|
||||||
|
task_type=task.task_type,
|
||||||
title=task.title,
|
title=task.title,
|
||||||
directive=task.directive,
|
directive=task.directive,
|
||||||
description=task.description,
|
description=task.description,
|
||||||
file_path=task.file_path,
|
file_path=task.file_path,
|
||||||
file_type=task.file_type,
|
file_type=task.file_type,
|
||||||
annotations_json=task.annotations_json,
|
annotations_json=task.annotations_json,
|
||||||
|
# Carried over explicitly, same reasoning as task_type above: a
|
||||||
|
# copy-on-write version that dropped the camera graph would leave
|
||||||
|
# a camera_measure task unable to execute from the moment a
|
||||||
|
# recipe with measurements is next edited.
|
||||||
|
vision_json=task.vision_json,
|
||||||
|
# Cartella immutabile: basta il riferimento (#46).
|
||||||
|
vision_recipe_dir=task.vision_recipe_dir,
|
||||||
|
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
||||||
|
in_loop=task.in_loop,
|
||||||
|
at_close=task.at_close,
|
||||||
|
fields=task.fields,
|
||||||
)
|
)
|
||||||
db.add(new_task)
|
db.add(new_task)
|
||||||
await db.flush() # get new_task.id
|
await db.flush() # get new_task.id
|
||||||
|
|
||||||
|
# Reference images (#38.6): the copied task points at the same files
|
||||||
|
# on disk as the source - same `path`, same acquisition metadata. A
|
||||||
|
# graph composed against a reference must keep working after the
|
||||||
|
# recipe is next edited, exactly like task_type/vision_json above.
|
||||||
|
# The file itself is only ever removed once no row references its
|
||||||
|
# path any more (see vision_service.file_still_referenced).
|
||||||
|
ref_images = (await db.execute(
|
||||||
|
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
|
||||||
|
)).scalars().all()
|
||||||
|
for ref in ref_images:
|
||||||
|
db.add(VisionReferenceImage(
|
||||||
|
task_id=new_task.id,
|
||||||
|
path=ref.path,
|
||||||
|
station_id=ref.station_id,
|
||||||
|
device_code=ref.device_code,
|
||||||
|
calibration_snapshot=ref.calibration_snapshot,
|
||||||
|
engine_version=ref.engine_version,
|
||||||
|
note=ref.note,
|
||||||
|
expected_json=ref.expected_json,
|
||||||
|
acquired_at=ref.acquired_at,
|
||||||
|
))
|
||||||
|
|
||||||
for sub in task.subtasks:
|
for sub in task.subtasks:
|
||||||
new_sub = RecipeSubtask(
|
new_sub = RecipeSubtask(
|
||||||
task_id=new_task.id,
|
task_id=new_task.id,
|
||||||
@@ -78,6 +160,7 @@ async def _copy_tasks_to_version(
|
|||||||
ltl=sub.ltl,
|
ltl=sub.ltl,
|
||||||
unit=sub.unit,
|
unit=sub.unit,
|
||||||
image_path=sub.image_path,
|
image_path=sub.image_path,
|
||||||
|
vision_output=sub.vision_output,
|
||||||
)
|
)
|
||||||
db.add(new_sub)
|
db.add(new_sub)
|
||||||
|
|
||||||
@@ -135,6 +218,9 @@ async def create_recipe(
|
|||||||
description=data.description,
|
description=data.description,
|
||||||
image_path=data.image_path,
|
image_path=data.image_path,
|
||||||
measurement_interval_minutes=data.measurement_interval_minutes,
|
measurement_interval_minutes=data.measurement_interval_minutes,
|
||||||
|
requires_lot=data.requires_lot,
|
||||||
|
requires_serial=data.requires_serial,
|
||||||
|
allow_manual_input=data.allow_manual_input,
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
)
|
)
|
||||||
db.add(recipe)
|
db.add(recipe)
|
||||||
@@ -173,6 +259,78 @@ async def create_recipe(
|
|||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def duplicate_recipe(
|
||||||
|
db: AsyncSession,
|
||||||
|
recipe_id: int,
|
||||||
|
new_code: str,
|
||||||
|
new_name: str,
|
||||||
|
user: User,
|
||||||
|
) -> Recipe:
|
||||||
|
"""Duplicate a recipe (#52): new code/name, v1 with the current version's tasks.
|
||||||
|
|
||||||
|
The source recipe is left untouched.
|
||||||
|
"""
|
||||||
|
source = await _get_recipe_or_404(db, recipe_id)
|
||||||
|
|
||||||
|
existing = await db.execute(select(Recipe).where(Recipe.code == new_code))
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Recipe code '{new_code}' already exists",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Same header fields create_recipe persists, taken from the source recipe
|
||||||
|
recipe = Recipe(
|
||||||
|
code=new_code,
|
||||||
|
name=new_name,
|
||||||
|
description=source.description,
|
||||||
|
image_path=source.image_path,
|
||||||
|
measurement_interval_minutes=source.measurement_interval_minutes,
|
||||||
|
requires_lot=source.requires_lot,
|
||||||
|
requires_serial=source.requires_serial,
|
||||||
|
allow_manual_input=source.allow_manual_input,
|
||||||
|
created_by=user.id,
|
||||||
|
)
|
||||||
|
db.add(recipe)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
version = RecipeVersion(
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
version_number=1,
|
||||||
|
is_current=True,
|
||||||
|
created_by=user.id,
|
||||||
|
change_notes=f"Duplicated from {source.code}",
|
||||||
|
)
|
||||||
|
db.add(version)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Reuse the existing deep copy of tasks/subtasks from the current version
|
||||||
|
source_version = await _get_current_version(db, source.id)
|
||||||
|
if source_version is not None:
|
||||||
|
await _copy_tasks_to_version(db, source_version, version)
|
||||||
|
|
||||||
|
await _write_audit(
|
||||||
|
db,
|
||||||
|
recipe_id=recipe.id,
|
||||||
|
old_version_id=None,
|
||||||
|
new_version_id=version.id,
|
||||||
|
changed_by=user.id,
|
||||||
|
change_type="CREATE",
|
||||||
|
change_reason=f"Duplicated from recipe {source.code}",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Recipe)
|
||||||
|
.where(Recipe.id == recipe.id)
|
||||||
|
.options(
|
||||||
|
selectinload(Recipe.versions)
|
||||||
|
.selectinload(RecipeVersion.tasks)
|
||||||
|
.selectinload(RecipeTask.subtasks)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
async def create_new_version(
|
async def create_new_version(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
@@ -254,6 +412,7 @@ async def create_new_version(
|
|||||||
default_task = RecipeTask(
|
default_task = RecipeTask(
|
||||||
version_id=new_version.id,
|
version_id=new_version.id,
|
||||||
order_index=0,
|
order_index=0,
|
||||||
|
task_type="drawing",
|
||||||
title="Technical Drawing",
|
title="Technical Drawing",
|
||||||
file_path=data.file_path,
|
file_path=data.file_path,
|
||||||
file_type=data.file_type or (
|
file_type=data.file_type or (
|
||||||
@@ -267,15 +426,7 @@ async def create_new_version(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Apply header updates
|
# Apply header updates
|
||||||
update_fields: dict = {}
|
update_fields = _header_fields(data)
|
||||||
if data.name is not None:
|
|
||||||
update_fields["name"] = data.name
|
|
||||||
if data.description is not None:
|
|
||||||
update_fields["description"] = data.description
|
|
||||||
if data.image_path is not None:
|
|
||||||
update_fields["image_path"] = data.image_path
|
|
||||||
if data.measurement_interval_minutes is not None:
|
|
||||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
|
||||||
if update_fields:
|
if update_fields:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||||
@@ -364,16 +515,7 @@ async def update_current_version(
|
|||||||
data: RecipeUpdate,
|
data: RecipeUpdate,
|
||||||
) -> RecipeVersion:
|
) -> RecipeVersion:
|
||||||
"""Update recipe header in-place on the current version (no copy-on-write)."""
|
"""Update recipe header in-place on the current version (no copy-on-write)."""
|
||||||
# Apply header updates (name, description, image_path)
|
update_fields = _header_fields(data)
|
||||||
update_fields: dict = {}
|
|
||||||
if data.name is not None:
|
|
||||||
update_fields["name"] = data.name
|
|
||||||
if data.description is not None:
|
|
||||||
update_fields["description"] = data.description
|
|
||||||
if data.image_path is not None:
|
|
||||||
update_fields["image_path"] = data.image_path
|
|
||||||
if data.measurement_interval_minutes is not None:
|
|
||||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
|
||||||
if update_fields:
|
if update_fields:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||||
@@ -405,6 +547,7 @@ async def update_current_version(
|
|||||||
default_task = RecipeTask(
|
default_task = RecipeTask(
|
||||||
version_id=current.id,
|
version_id=current.id,
|
||||||
order_index=0,
|
order_index=0,
|
||||||
|
task_type="drawing",
|
||||||
title="Technical Drawing",
|
title="Technical Drawing",
|
||||||
file_path=data.file_path,
|
file_path=data.file_path,
|
||||||
file_type=data.file_type or (
|
file_type=data.file_type or (
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.utils.tempo import to_local, utcnow
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
@@ -31,6 +33,11 @@ _jinja_env = Environment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _giorno_locale(dt: datetime) -> str:
|
||||||
|
"""UTC naive filter bound -> local calendar day (YYYY-MM-DD)."""
|
||||||
|
return to_local(dt).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
async def _query_measurements(
|
async def _query_measurements(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
@@ -41,6 +48,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
|
||||||
) -> list[Measurement]:
|
) -> list[Measurement]:
|
||||||
"""Query measurements with common filters (duplicated from statistics router)."""
|
"""Query measurements with common filters (duplicated from statistics router)."""
|
||||||
filters = []
|
filters = []
|
||||||
@@ -66,6 +74,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)
|
||||||
@@ -204,6 +215,11 @@ def _generate_control_chart_svg(chart_data, subtask_info: dict | None) -> str:
|
|||||||
return svg_bytes.decode("utf-8")
|
return svg_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_pdf(html_content: str) -> bytes:
|
||||||
|
"""Blocking WeasyPrint render — always call via asyncio.to_thread."""
|
||||||
|
return HTML(string=html_content).write_pdf()
|
||||||
|
|
||||||
|
|
||||||
def _generate_histogram_svg(hist_data, subtask_info: dict | None) -> str:
|
def _generate_histogram_svg(hist_data, subtask_info: dict | None) -> str:
|
||||||
"""Generate histogram SVG using Plotly + Kaleido."""
|
"""Generate histogram SVG using Plotly + Kaleido."""
|
||||||
if not hist_data.bins:
|
if not hist_data.bins:
|
||||||
@@ -269,6 +285,7 @@ async def generate_spc_report(
|
|||||||
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
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Generate SPC PDF report and return bytes.
|
"""Generate SPC PDF report and return bytes.
|
||||||
|
|
||||||
@@ -278,6 +295,7 @@ async def generate_spc_report(
|
|||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Compute SPC data
|
# 2. Compute SPC data
|
||||||
@@ -300,9 +318,13 @@ async def generate_spc_report(
|
|||||||
)
|
)
|
||||||
histogram = compute_histogram(values)
|
histogram = compute_histogram(values)
|
||||||
|
|
||||||
# 3. Generate SVG charts
|
# 3. Generate SVG charts (kaleido shells out and blocks: off the event loop)
|
||||||
control_chart_svg = _generate_control_chart_svg(control_chart, subtask_info)
|
control_chart_svg = await asyncio.to_thread(
|
||||||
histogram_svg = _generate_histogram_svg(histogram, subtask_info)
|
_generate_control_chart_svg, control_chart, subtask_info
|
||||||
|
)
|
||||||
|
histogram_svg = await asyncio.to_thread(
|
||||||
|
_generate_histogram_svg, histogram, subtask_info
|
||||||
|
)
|
||||||
|
|
||||||
# 4. Get company info and recipe info
|
# 4. Get company info and recipe info
|
||||||
company = await _get_company_info(db)
|
company = await _get_company_info(db)
|
||||||
@@ -313,13 +335,15 @@ async def generate_spc_report(
|
|||||||
if version_id:
|
if version_id:
|
||||||
filters_desc.append(f"Version: {version_id}")
|
filters_desc.append(f"Version: {version_id}")
|
||||||
if date_from:
|
if date_from:
|
||||||
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"From: {_giorno_locale(date_from)}")
|
||||||
if date_to:
|
if date_to:
|
||||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||||
if lot_number:
|
if lot_number:
|
||||||
filters_desc.append(f"Lot: {lot_number}")
|
filters_desc.append(f"Lot: {lot_number}")
|
||||||
if serial_number:
|
if serial_number:
|
||||||
filters_desc.append(f"Serial: {serial_number}")
|
filters_desc.append(f"Serial: {serial_number}")
|
||||||
|
if production_run_id is not None:
|
||||||
|
filters_desc.append(f"Run: {production_run_id}") # #58
|
||||||
|
|
||||||
# 6. Render HTML template
|
# 6. Render HTML template
|
||||||
template = _jinja_env.get_template("spc_report.html")
|
template = _jinja_env.get_template("spc_report.html")
|
||||||
@@ -332,12 +356,12 @@ async def generate_spc_report(
|
|||||||
control_chart_svg=control_chart_svg,
|
control_chart_svg=control_chart_svg,
|
||||||
histogram_svg=histogram_svg,
|
histogram_svg=histogram_svg,
|
||||||
filters_desc=filters_desc,
|
filters_desc=filters_desc,
|
||||||
generated_at=datetime.now(),
|
generated_at=to_local(utcnow()),
|
||||||
n_measurements=len(measurements),
|
n_measurements=len(measurements),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 7. Convert HTML to PDF
|
# 7. Convert HTML to PDF (WeasyPrint blocks: off the event loop)
|
||||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
|
||||||
return pdf_bytes
|
return pdf_bytes
|
||||||
|
|
||||||
|
|
||||||
@@ -351,12 +375,14 @@ async def generate_measurement_report(
|
|||||||
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
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Generate measurement table PDF report and return bytes."""
|
"""Generate measurement table PDF report and return bytes."""
|
||||||
# 1. Query measurements
|
# 1. Query measurements
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Get recipe info
|
# 2. Get recipe info
|
||||||
@@ -385,7 +411,7 @@ async def generate_measurement_report(
|
|||||||
"value": f"{float(m.value):.4f}",
|
"value": f"{float(m.value):.4f}",
|
||||||
"unit": st_info.get("unit", "mm"),
|
"unit": st_info.get("unit", "mm"),
|
||||||
"pass_fail": m.pass_fail,
|
"pass_fail": m.pass_fail,
|
||||||
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"),
|
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
||||||
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
|
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
|
||||||
"lot_number": m.lot_number or "—",
|
"lot_number": m.lot_number or "—",
|
||||||
"serial_number": m.serial_number or "—",
|
"serial_number": m.serial_number or "—",
|
||||||
@@ -404,13 +430,15 @@ async def generate_measurement_report(
|
|||||||
if version_id:
|
if version_id:
|
||||||
filters_desc.append(f"Version: {version_id}")
|
filters_desc.append(f"Version: {version_id}")
|
||||||
if date_from:
|
if date_from:
|
||||||
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"From: {_giorno_locale(date_from)}")
|
||||||
if date_to:
|
if date_to:
|
||||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||||
if lot_number:
|
if lot_number:
|
||||||
filters_desc.append(f"Lot: {lot_number}")
|
filters_desc.append(f"Lot: {lot_number}")
|
||||||
if serial_number:
|
if serial_number:
|
||||||
filters_desc.append(f"Serial: {serial_number}")
|
filters_desc.append(f"Serial: {serial_number}")
|
||||||
|
if production_run_id is not None:
|
||||||
|
filters_desc.append(f"Run: {production_run_id}") # #58
|
||||||
|
|
||||||
# 7. Render template
|
# 7. Render template
|
||||||
template = _jinja_env.get_template("measurement_report.html")
|
template = _jinja_env.get_template("measurement_report.html")
|
||||||
@@ -420,10 +448,10 @@ async def generate_measurement_report(
|
|||||||
rows=rows,
|
rows=rows,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
filters_desc=filters_desc,
|
filters_desc=filters_desc,
|
||||||
generated_at=datetime.now(),
|
generated_at=to_local(utcnow()),
|
||||||
n_measurements=len(measurements),
|
n_measurements=len(measurements),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 8. Convert to PDF
|
# 8. Convert to PDF (WeasyPrint blocks: off the event loop)
|
||||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
|
||||||
return pdf_bytes
|
return pdf_bytes
|
||||||
|
|||||||
@@ -55,6 +55,27 @@ def compute_summary(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sigma_within(values: list[float]) -> float | None:
|
||||||
|
"""Estimate within-subgroup sigma from the moving range of individuals.
|
||||||
|
|
||||||
|
sigma_within = MRbar / d2, with d2 = 1.128 (moving range of consecutive
|
||||||
|
pairs, i.e. subgroup size 2). `values` must be in chronological order
|
||||||
|
(the order in which measurements arrived) — the moving range is not
|
||||||
|
meaningful on a re-sorted series.
|
||||||
|
|
||||||
|
Returns None when fewer than 2 values are available, or when MRbar == 0
|
||||||
|
(no variation between consecutive points: the index would be undefined).
|
||||||
|
"""
|
||||||
|
n = len(values)
|
||||||
|
if n < 2:
|
||||||
|
return None
|
||||||
|
moving_ranges = [abs(values[i] - values[i - 1]) for i in range(1, n)]
|
||||||
|
mr_bar = stats.mean(moving_ranges)
|
||||||
|
if mr_bar == 0:
|
||||||
|
return None
|
||||||
|
return mr_bar / 1.128
|
||||||
|
|
||||||
|
|
||||||
def compute_capability(
|
def compute_capability(
|
||||||
values: list[float],
|
values: list[float],
|
||||||
utl: float | None,
|
utl: float | None,
|
||||||
@@ -63,12 +84,18 @@ def compute_capability(
|
|||||||
) -> CapabilityData:
|
) -> CapabilityData:
|
||||||
"""Compute capability indices Cp, Cpk, Pp, Ppk.
|
"""Compute capability indices Cp, Cpk, Pp, Ppk.
|
||||||
|
|
||||||
|
`values` must be in chronological order (arrival order), since Cp/Cpk are
|
||||||
|
derived from the moving range between consecutive points:
|
||||||
|
|
||||||
|
- sigma_within = MRbar / 1.128 (MRbar = mean of |x_i - x_(i-1)|)
|
||||||
- Cp = (UTL - LTL) / (6 * sigma_within)
|
- Cp = (UTL - LTL) / (6 * sigma_within)
|
||||||
- Cpk = min((UTL - mean) / (3 * sigma), (mean - LTL) / (3 * sigma))
|
- Cpk = min((UTL - mean) / (3 * sigma_within), (mean - LTL) / (3 * sigma_within))
|
||||||
- Pp/Ppk: same formulas using population std dev (same data, no subgrouping)
|
- Pp/Ppk: same formulas using the overall sample std dev (n-1, `stats.stdev`)
|
||||||
|
of the whole series — this is the "process performance" pair and is
|
||||||
|
allowed to be wider than Cp/Cpk when there is drift between subgroups.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
values: Measured values.
|
values: Measured values, in chronological order.
|
||||||
utl: Upper Tolerance Limit (None if not defined).
|
utl: Upper Tolerance Limit (None if not defined).
|
||||||
ltl: Lower Tolerance Limit (None if not defined).
|
ltl: Lower Tolerance Limit (None if not defined).
|
||||||
nominal: Nominal value (None if not defined).
|
nominal: Nominal value (None if not defined).
|
||||||
@@ -81,39 +108,39 @@ def compute_capability(
|
|||||||
return CapabilityData(
|
return CapabilityData(
|
||||||
cp=None, cpk=None, pp=None, ppk=None,
|
cp=None, cpk=None, pp=None, ppk=None,
|
||||||
mean=values[0] if n == 1 else 0.0,
|
mean=values[0] if n == 1 else 0.0,
|
||||||
std_dev=0.0, n=n,
|
std_dev=0.0, std_dev_within=None, n=n,
|
||||||
utl=utl, ltl=ltl, nominal=nominal,
|
utl=utl, ltl=ltl, nominal=nominal,
|
||||||
)
|
)
|
||||||
|
|
||||||
mean = stats.mean(values)
|
mean = stats.mean(values)
|
||||||
# Population std dev for Pp/Ppk
|
# Overall sample std dev (n-1) for Pp/Ppk
|
||||||
std_dev_pop = stats.pstdev(values)
|
|
||||||
# Sample std dev for Cp/Cpk
|
|
||||||
std_dev_sample = stats.stdev(values)
|
std_dev_sample = stats.stdev(values)
|
||||||
|
# Within-subgroup sigma (moving range) for Cp/Cpk
|
||||||
|
sigma_within = _sigma_within(values)
|
||||||
|
|
||||||
cp = cpk = pp = ppk = None
|
cp = cpk = pp = ppk = None
|
||||||
|
|
||||||
if utl is not None and ltl is not None and std_dev_sample > 0:
|
if sigma_within is not None:
|
||||||
cp = round((utl - ltl) / (6 * std_dev_sample), 4)
|
if utl is not None and ltl is not None:
|
||||||
|
cp = round((utl - ltl) / (6 * sigma_within), 4)
|
||||||
|
|
||||||
if std_dev_sample > 0:
|
|
||||||
cpk_values = []
|
cpk_values = []
|
||||||
if utl is not None:
|
if utl is not None:
|
||||||
cpk_values.append((utl - mean) / (3 * std_dev_sample))
|
cpk_values.append((utl - mean) / (3 * sigma_within))
|
||||||
if ltl is not None:
|
if ltl is not None:
|
||||||
cpk_values.append((mean - ltl) / (3 * std_dev_sample))
|
cpk_values.append((mean - ltl) / (3 * sigma_within))
|
||||||
if cpk_values:
|
if cpk_values:
|
||||||
cpk = round(min(cpk_values), 4)
|
cpk = round(min(cpk_values), 4)
|
||||||
|
|
||||||
if utl is not None and ltl is not None and std_dev_pop > 0:
|
if std_dev_sample > 0:
|
||||||
pp = round((utl - ltl) / (6 * std_dev_pop), 4)
|
if utl is not None and ltl is not None:
|
||||||
|
pp = round((utl - ltl) / (6 * std_dev_sample), 4)
|
||||||
|
|
||||||
if std_dev_pop > 0:
|
|
||||||
ppk_values = []
|
ppk_values = []
|
||||||
if utl is not None:
|
if utl is not None:
|
||||||
ppk_values.append((utl - mean) / (3 * std_dev_pop))
|
ppk_values.append((utl - mean) / (3 * std_dev_sample))
|
||||||
if ltl is not None:
|
if ltl is not None:
|
||||||
ppk_values.append((mean - ltl) / (3 * std_dev_pop))
|
ppk_values.append((mean - ltl) / (3 * std_dev_sample))
|
||||||
if ppk_values:
|
if ppk_values:
|
||||||
ppk = round(min(ppk_values), 4)
|
ppk = round(min(ppk_values), 4)
|
||||||
|
|
||||||
@@ -121,6 +148,7 @@ def compute_capability(
|
|||||||
cp=cp, cpk=cpk, pp=pp, ppk=ppk,
|
cp=cp, cpk=cpk, pp=pp, ppk=ppk,
|
||||||
mean=round(mean, 6),
|
mean=round(mean, 6),
|
||||||
std_dev=round(std_dev_sample, 6),
|
std_dev=round(std_dev_sample, 6),
|
||||||
|
std_dev_within=round(sigma_within, 6) if sigma_within is not None else None,
|
||||||
n=n,
|
n=n,
|
||||||
utl=utl, ltl=ltl, nominal=nominal,
|
utl=utl, ltl=ltl, nominal=nominal,
|
||||||
)
|
)
|
||||||
@@ -135,10 +163,12 @@ def compute_control_chart(
|
|||||||
ltl: float | None,
|
ltl: float | None,
|
||||||
nominal: float | None,
|
nominal: float | None,
|
||||||
) -> ControlChartData:
|
) -> ControlChartData:
|
||||||
"""Compute control chart data with UCL/LCL and out-of-control detection.
|
"""Compute control chart data (individuals chart) with UCL/LCL and
|
||||||
|
out-of-control detection.
|
||||||
|
|
||||||
UCL = mean + 3*sigma
|
UCL = mean + 3*sigma_within = mean + 2.66*MRbar
|
||||||
LCL = mean - 3*sigma
|
LCL = mean - 3*sigma_within = mean - 2.66*MRbar
|
||||||
|
with sigma_within = MRbar/1.128 (see `_sigma_within`).
|
||||||
Out-of-control: points outside UCL/LCL.
|
Out-of-control: points outside UCL/LCL.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -160,10 +190,8 @@ def compute_control_chart(
|
|||||||
|
|
||||||
mean = stats.mean(values)
|
mean = stats.mean(values)
|
||||||
|
|
||||||
if n >= 2:
|
sigma_within = _sigma_within(values)
|
||||||
sigma = stats.stdev(values)
|
sigma = sigma_within if sigma_within is not None else 0.0
|
||||||
else:
|
|
||||||
sigma = 0.0
|
|
||||||
|
|
||||||
ucl = mean + 3 * sigma
|
ucl = mean + 3 * sigma
|
||||||
lcl = mean - 3 * sigma
|
lcl = mean - 3 * sigma
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ async def create_station(
|
|||||||
location=data.location,
|
location=data.location,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
active=data.active,
|
active=data.active,
|
||||||
|
vision_worker_url=data.vision_worker_url, # #45
|
||||||
created_by=creator.id,
|
created_by=creator.id,
|
||||||
)
|
)
|
||||||
db.add(station)
|
db.add(station)
|
||||||
@@ -138,6 +139,26 @@ async def unassign_recipe(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def unassign_all_recipes(db: AsyncSession, station_id: int) -> int:
|
||||||
|
"""Drop every recipe assignment of a station and return how many were removed.
|
||||||
|
|
||||||
|
Used by the per-row reset in the admin station list: the station goes back to
|
||||||
|
having no recipes so it can be reassigned from scratch. Unlike unassign_recipe
|
||||||
|
this is idempotent - resetting an already empty station returns 0 rather than 404.
|
||||||
|
"""
|
||||||
|
await get_station(db, station_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(StationRecipeAssignment).where(
|
||||||
|
StationRecipeAssignment.station_id == station_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assignments = list(result.scalars().all())
|
||||||
|
for assignment in assignments:
|
||||||
|
await db.delete(assignment)
|
||||||
|
await db.flush()
|
||||||
|
return len(assignments)
|
||||||
|
|
||||||
|
|
||||||
async def list_station_recipes(
|
async def list_station_recipes(
|
||||||
db: AsyncSession, station_id: int,
|
db: AsyncSession, station_id: int,
|
||||||
) -> list[Recipe]:
|
) -> list[Recipe]:
|
||||||
|
|||||||
@@ -0,0 +1,603 @@
|
|||||||
|
"""Route the image to the worker, map the outputs onto the quotes, save.
|
||||||
|
|
||||||
|
This module never imports VisionSuite - see test_vision_boundary.py. It speaks
|
||||||
|
to the worker over HTTP and stays ignorant of how a number was produced.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
|
from src.backend.services import measurement_service
|
||||||
|
|
||||||
|
# Worker responses whose status and reason are forwarded as-is rather than
|
||||||
|
# flattened into a generic 502 - see `_call_worker`.
|
||||||
|
_FORWARDED_WORKER_STATUSES = frozenset({
|
||||||
|
status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _is_measurable(value: object) -> bool:
|
||||||
|
"""A worker output counts as a quote only if it is a finite, non-bool number.
|
||||||
|
|
||||||
|
None (absent, or explicitly null in a malformed response), a bool (a
|
||||||
|
subclass of int), and NaN/inf all fail this - each one would otherwise
|
||||||
|
reach `float()` and either crash with a bare TypeError or silently save a
|
||||||
|
value nobody could have measured.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
isinstance(value, (int, float))
|
||||||
|
and not isinstance(value, bool)
|
||||||
|
and math.isfinite(value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_worker(
|
||||||
|
image_bytes: bytes,
|
||||||
|
graph: dict,
|
||||||
|
*,
|
||||||
|
recipe_dir: str | None = None,
|
||||||
|
calibration: dict | None = None,
|
||||||
|
worker_url: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""The one place that talks to the worker; the tests replace it.
|
||||||
|
|
||||||
|
The worker's fault must not become the API's fault (spec's edge-case
|
||||||
|
table: "il worker isola il guasto, l'API resta in piedi, il task
|
||||||
|
riporta l'errore"). httpx.TimeoutException (ConnectTimeout, ReadTimeout,
|
||||||
|
WriteTimeout, PoolTimeout) becomes 504; every other transport failure
|
||||||
|
(ConnectError, ReadError, RemoteProtocolError when the worker dies
|
||||||
|
mid-response, ...) becomes 502 - both would otherwise propagate past
|
||||||
|
this function and surface to the operator as a naked 500. The response
|
||||||
|
shape is validated here too: a proxy answering 200 with something that
|
||||||
|
is not the worker's JSON contract must not turn into a bare KeyError
|
||||||
|
further down.
|
||||||
|
"""
|
||||||
|
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
|
||||||
|
# si mandano, il worker usa i suoi default.
|
||||||
|
data = {"graph": json.dumps(graph)}
|
||||||
|
# worker della stazione (#45) o, se manca, il centrale; mai un ripiego dall'uno all'altro
|
||||||
|
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||||
|
if recipe_dir:
|
||||||
|
data["recipe_dir"] = recipe_dir
|
||||||
|
if calibration:
|
||||||
|
data["calibration"] = json.dumps(calibration)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{base}/run",
|
||||||
|
files={"image": ("frame.png", image_bytes, "image/png")},
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail=f"the vision worker did not answer in time: {exc} ({base})", # #45
|
||||||
|
) from exc
|
||||||
|
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker is unreachable: {exc} ({base})", # #45
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# The worker already gave this fault a name - forward its status and
|
||||||
|
# reason instead of flattening every non-200 into a generic 502: 422 is a
|
||||||
|
# bad request (undecodable image, malformed graph), 503 is "busy, try
|
||||||
|
# again" (VISION_MAX_CONCURRENCY exceeded), 504 is its own run timeout
|
||||||
|
# (VISION_RUN_TIMEOUT_S, always tighter than this client's own 120s so
|
||||||
|
# the worker answers first).
|
||||||
|
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
||||||
|
try:
|
||||||
|
detail = response.json().get("detail", response.text)
|
||||||
|
except ValueError:
|
||||||
|
detail = response.text
|
||||||
|
raise HTTPException(status_code=response.status_code, detail=detail)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker refused the job: {response.text}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
report = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(report, dict):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||||
|
)
|
||||||
|
missing = {"outputs", "failures", "engine_version"} - report.keys()
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=(
|
||||||
|
"the vision worker's answer was malformed: missing "
|
||||||
|
f"{', '.join(sorted(missing))}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
||||||
|
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
||||||
|
|
||||||
|
Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir
|
||||||
|
refused) is folded into ok=False; transport faults become 502/504 as in
|
||||||
|
`_call_worker`. Replaced by the tests.
|
||||||
|
"""
|
||||||
|
data = {"graph": json.dumps(graph)}
|
||||||
|
if recipe_dir:
|
||||||
|
data["recipe_dir"] = recipe_dir
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.vision_worker_url}/validate", data=data,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
detail=f"the vision worker did not answer in time: {exc}",
|
||||||
|
) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker is unreachable: {exc}",
|
||||||
|
) from exc
|
||||||
|
if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT:
|
||||||
|
try:
|
||||||
|
detail = response.json().get("detail", response.text)
|
||||||
|
except ValueError:
|
||||||
|
detail = response.text
|
||||||
|
errors = detail if isinstance(detail, list) else [str(detail)]
|
||||||
|
return {"ok": False, "errors": [str(e) for e in errors], "outputs": []}
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"the vision worker refused the job: {response.text}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: not valid JSON",
|
||||||
|
) from exc
|
||||||
|
if not isinstance(body, dict) or "ok" not in body:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="the vision worker's answer was malformed: missing ok",
|
||||||
|
)
|
||||||
|
body.setdefault("errors", [])
|
||||||
|
body.setdefault("outputs", [])
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
# Task types that run a vision graph (#46).
|
||||||
|
VISION_TASK_TYPES = ("camera_measure", "dxf_compare")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_join(root: Path, relative: str) -> Path:
|
||||||
|
"""root/relative, refusing absolute paths, '..' and anything leaving root."""
|
||||||
|
candidate = Path(relative)
|
||||||
|
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
|
||||||
|
raise ValueError(f"unsafe path: {relative}")
|
||||||
|
full = (root / candidate).resolve()
|
||||||
|
if not full.is_relative_to(root.resolve()):
|
||||||
|
raise ValueError(f"unsafe path: {relative}")
|
||||||
|
return full
|
||||||
|
|
||||||
|
|
||||||
|
def collect_meta_paths(node: object) -> list[str]:
|
||||||
|
"""Every `path` string found in any dict under the recipe's `meta`."""
|
||||||
|
found: list[str] = []
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
if key == "path" and isinstance(value, str):
|
||||||
|
found.append(value)
|
||||||
|
else:
|
||||||
|
found.extend(collect_meta_paths(value))
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
found.extend(collect_meta_paths(item))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def match_recipe_files(
|
||||||
|
recipe: dict, uploads: list[tuple[str, bytes]],
|
||||||
|
) -> tuple[dict[str, bytes], list[str]]:
|
||||||
|
"""Pair each uploaded file with a meta path by base name (#46).
|
||||||
|
|
||||||
|
Returns ({relative path: bytes}, errors). Errors list every upload that
|
||||||
|
has no name or matches no meta path, and every unsafe meta path. A meta
|
||||||
|
path left without a file is not an error here: /validate decides.
|
||||||
|
"""
|
||||||
|
paths = collect_meta_paths(recipe.get("meta", {}))
|
||||||
|
by_name: dict[str, str] = {}
|
||||||
|
for p in paths:
|
||||||
|
by_name.setdefault(Path(p.replace("\\", "/")).name, p)
|
||||||
|
placed: dict[str, bytes] = {}
|
||||||
|
errors: list[str] = []
|
||||||
|
for name, content in uploads:
|
||||||
|
if not name:
|
||||||
|
errors.append("a file has no name")
|
||||||
|
continue
|
||||||
|
target = by_name.get(Path(name.replace("\\", "/")).name)
|
||||||
|
if target is None:
|
||||||
|
errors.append(f"file {name!r} matches no path in the recipe's meta")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_safe_join(Path("."), target)
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f"meta path {target!r} is not allowed")
|
||||||
|
continue
|
||||||
|
placed[target] = content
|
||||||
|
return placed, errors
|
||||||
|
|
||||||
|
|
||||||
|
def store_recipe_folder(
|
||||||
|
task_id: int, recipe: dict, files: dict[str, bytes],
|
||||||
|
) -> str:
|
||||||
|
"""Write the immutable recipe folder; return its path relative to uploads.
|
||||||
|
|
||||||
|
Folder name = first 16 hex of the recipe's sha256. If it already exists it
|
||||||
|
is left untouched (immutable: other task versions may reference it).
|
||||||
|
"""
|
||||||
|
canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":"))
|
||||||
|
# Il digest copre anche i file (path ordinato + sha256 del contenuto): la
|
||||||
|
# stessa ricetta con file diversi (es. primo upload senza il DXF) deve dare
|
||||||
|
# una cartella diversa, altrimenti quella incompleta resterebbe per sempre
|
||||||
|
# perche' immutabile (#46).
|
||||||
|
h = hashlib.sha256(canonical.encode("utf-8"))
|
||||||
|
for rel in sorted(files):
|
||||||
|
h.update(b"\0" + rel.encode("utf-8") + b"\0")
|
||||||
|
h.update(hashlib.sha256(files[rel]).digest())
|
||||||
|
digest = h.hexdigest()[:16]
|
||||||
|
relative = f"vision/recipes/{task_id}/{digest}"
|
||||||
|
folder = settings.upload_path / relative
|
||||||
|
if folder.exists():
|
||||||
|
return relative
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
(folder / "recipe.json").write_text(
|
||||||
|
json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||||
|
)
|
||||||
|
for rel, content in files.items():
|
||||||
|
target = _safe_join(folder, rel)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(content)
|
||||||
|
return relative
|
||||||
|
|
||||||
|
|
||||||
|
def list_recipe_files(recipe_dir: str | None) -> list[str]:
|
||||||
|
"""Files of a recipe folder, relative to it (recipe.json excluded)."""
|
||||||
|
if not recipe_dir:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
if not folder.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
p.relative_to(folder).as_posix()
|
||||||
|
for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_result_files(
|
||||||
|
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Save the executed image and its overlay JSON side by side (#46).
|
||||||
|
|
||||||
|
Returns (image_path, overlay_path), both relative to uploads.
|
||||||
|
"""
|
||||||
|
stem = uuid4().hex
|
||||||
|
base = Path("vision") / "results" / str(task_id)
|
||||||
|
(settings.upload_path / base).mkdir(parents=True, exist_ok=True)
|
||||||
|
image_rel = (base / f"{stem}.{image_ext}").as_posix()
|
||||||
|
overlay_rel = (base / f"{stem}.overlay.json").as_posix()
|
||||||
|
(settings.upload_path / image_rel).write_bytes(image_bytes)
|
||||||
|
(settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8")
|
||||||
|
return image_rel, overlay_rel
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_task(
|
||||||
|
db: AsyncSession,
|
||||||
|
task: RecipeTask,
|
||||||
|
image_bytes: bytes,
|
||||||
|
measured_by: int,
|
||||||
|
*,
|
||||||
|
version_id: int,
|
||||||
|
lot_number: str | None = None,
|
||||||
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None,
|
||||||
|
image_ext: str = "png",
|
||||||
|
) -> tuple[VisionResult, list[Measurement], dict]:
|
||||||
|
if task.task_type not in VISION_TASK_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"task type {task.task_type!r} is not a camera measurement",
|
||||||
|
)
|
||||||
|
|
||||||
|
version = (await db.execute(
|
||||||
|
select(RecipeVersion).where(RecipeVersion.id == version_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if version is None or not version.is_current:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="this task's recipe version is no longer current",
|
||||||
|
)
|
||||||
|
|
||||||
|
recipe = await measurement_service.recipe_of_version(db, version_id)
|
||||||
|
if recipe is None or not recipe.active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="this recipe is not active",
|
||||||
|
)
|
||||||
|
|
||||||
|
run = None
|
||||||
|
if production_run_id is not None:
|
||||||
|
# 404 if the run does not exist, 422 if it belongs to another recipe,
|
||||||
|
# 409 if it is closed or paused - same rule a manual reading follows.
|
||||||
|
run = await measurement_service.validate_run_for_version(
|
||||||
|
db, production_run_id, version_id,
|
||||||
|
)
|
||||||
|
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||||
|
station_url = None
|
||||||
|
if run is not None:
|
||||||
|
station = (await db.execute(
|
||||||
|
select(Station).where(Station.id == run.station_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||||
|
|
||||||
|
if not task.vision_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="this task carries no vision graph",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ordered by marker_number - the number the operator sees on the annotated
|
||||||
|
# image and in reports (RecipeSubtask.__repr__, statistics.py, report_service.py
|
||||||
|
# all key off it). Which quote is saved first must never be an accident of
|
||||||
|
# how SQLite/MySQL happens to return rows.
|
||||||
|
quotes = (await db.execute(
|
||||||
|
select(RecipeSubtask)
|
||||||
|
.where(RecipeSubtask.task_id == task.id)
|
||||||
|
.where(RecipeSubtask.vision_output.is_not(None))
|
||||||
|
.order_by(RecipeSubtask.marker_number)
|
||||||
|
)).scalars().all()
|
||||||
|
if not quotes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="no quote on this task names a vision output",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ruling R10: the gate is evaluated once per acquisition, not once per
|
||||||
|
# quote. `save_measurement`'s own gate models "moving on" between
|
||||||
|
# measurements taken one after another - a real later moment where the
|
||||||
|
# operator could leave a bad reading behind. A camera acquisition produces
|
||||||
|
# every quote of this task at the same instant: there is no "moving on"
|
||||||
|
# between them, so applying the sequential rule inside the batch would
|
||||||
|
# block on a fail this very request just saved. Check it once here,
|
||||||
|
# against the whole set this acquisition is about to produce, and pass
|
||||||
|
# enforce_tolerance_gate=False to every save_measurement call below.
|
||||||
|
#
|
||||||
|
# Checked before the worker runs (M3): a blocked operator finds out
|
||||||
|
# without burning a whole vision execution first - the worker is the
|
||||||
|
# expensive part, the gate check is not.
|
||||||
|
blocking = await measurement_service.pending_authorisation(
|
||||||
|
db, version_id, measured_by, production_run_id,
|
||||||
|
)
|
||||||
|
quote_ids = {q.id for q in quotes}
|
||||||
|
if blocking is not None and blocking.subtask_id not in quote_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"A measurement is out of tolerance and awaiting the supervisor: "
|
||||||
|
"authorise it or measure that quote again before going on"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
|
||||||
|
# passa `calibration`, la ricetta porta la sua (#46).
|
||||||
|
report = await _call_worker(
|
||||||
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
|
worker_url=station_url, # #45
|
||||||
|
)
|
||||||
|
outputs = report["outputs"]
|
||||||
|
|
||||||
|
# Every quote or none. A half-measured piece read as a whole one is worse
|
||||||
|
# than a piece not measured at all. A quote counts as produced only if its
|
||||||
|
# output is present *and* a finite, non-bool number: the in-process runner
|
||||||
|
# already drops NaN/inf itself (see src/vision/runner.py), but the worker
|
||||||
|
# is reached over HTTP - a different implementation of it, or a malformed
|
||||||
|
# response, could still hand back None or a non-finite float for a mapped
|
||||||
|
# key instead of omitting it outright.
|
||||||
|
missing = [q.vision_output for q in quotes if not _is_measurable(outputs.get(q.vision_output))]
|
||||||
|
if missing:
|
||||||
|
failed = ", ".join(f["tool_name"] for f in report["failures"])
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=(
|
||||||
|
f"the graph did not produce {', '.join(missing)}"
|
||||||
|
+ (f" (tools that failed: {failed})" if failed else "")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
overlay = report.get("overlay") or []
|
||||||
|
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
|
||||||
|
result = VisionResult(
|
||||||
|
task_id=task.id,
|
||||||
|
image_path=image_path,
|
||||||
|
overlay_path=overlay_path,
|
||||||
|
engine_version=report["engine_version"],
|
||||||
|
# #45: dove e' girato davvero, e quale worker (copiato come lo snapshot)
|
||||||
|
executed_on="station" if station_url else "server",
|
||||||
|
station_id=run.station_id if run is not None else None,
|
||||||
|
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||||
|
graph_snapshot=task.vision_json,
|
||||||
|
duration_ms=report.get("duration_ms"),
|
||||||
|
)
|
||||||
|
db.add(result)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
saved: list[Measurement] = []
|
||||||
|
for quote in quotes:
|
||||||
|
saved.append(await measurement_service.save_measurement(
|
||||||
|
db,
|
||||||
|
subtask_id=quote.id,
|
||||||
|
version_id=version_id,
|
||||||
|
measured_by=measured_by,
|
||||||
|
value=float(outputs[quote.vision_output]),
|
||||||
|
lot_number=lot_number,
|
||||||
|
serial_number=serial_number,
|
||||||
|
input_method="camera",
|
||||||
|
production_run_id=production_run_id,
|
||||||
|
vision_result_id=result.id,
|
||||||
|
enforce_tolerance_gate=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
return result, saved, report
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
||||||
|
return settings.upload_path / reference.path
|
||||||
|
|
||||||
|
|
||||||
|
def remove_reference_file(reference: VisionReferenceImage) -> None:
|
||||||
|
"""Delete a reference image's file, if it is still there.
|
||||||
|
|
||||||
|
Called by the router only once the row's fate is durable: after a
|
||||||
|
successful commit when the row was just deleted, or to undo a
|
||||||
|
`save_reference_image` whose row failed to commit (see both call sites).
|
||||||
|
`missing_ok` because "already gone" must never be an error here.
|
||||||
|
"""
|
||||||
|
_reference_file_path(reference).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def file_still_referenced(db: AsyncSession, path: str) -> bool:
|
||||||
|
"""True if some VisionReferenceImage row still points at *path*.
|
||||||
|
|
||||||
|
Copy-on-write (#38.6) can leave several rows sharing the same file on
|
||||||
|
disk (the copy carries over the source's `path` rather than duplicating
|
||||||
|
the file). The router must call this, after the row being deleted has
|
||||||
|
been committed, before it calls `remove_reference_file` - otherwise
|
||||||
|
deleting one task's reference would take the file out from under every
|
||||||
|
other task that still references it.
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(VisionReferenceImage.id).where(VisionReferenceImage.path == path).limit(1)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def save_reference_image(
|
||||||
|
db: AsyncSession,
|
||||||
|
task_id: int,
|
||||||
|
image_bytes: bytes,
|
||||||
|
*,
|
||||||
|
note: str | None = None,
|
||||||
|
station_id: int | None = None,
|
||||||
|
device_code: str | None = None,
|
||||||
|
calibration_snapshot: dict | None = None,
|
||||||
|
extension: str = "png",
|
||||||
|
) -> VisionReferenceImage:
|
||||||
|
"""Keep an image so the graph can be composed and re-tried without a camera.
|
||||||
|
|
||||||
|
`extension` must match the upload's real content type (the router derives
|
||||||
|
it from `image.content_type`) - writing a jpeg to disk as `<uuid>.png`
|
||||||
|
would be a lie the filename tells forever.
|
||||||
|
|
||||||
|
The file is written only after the row has flushed successfully - a bad
|
||||||
|
FK or constraint fails at `flush()`, before anything exists on disk, so
|
||||||
|
there is nothing to clean up. The caller still has to remove the file if
|
||||||
|
the *commit* that follows fails (`remove_reference_file`, from the
|
||||||
|
router): a flush is not durable, and an orphaned file from a rolled-back
|
||||||
|
row is exactly what #38 found.
|
||||||
|
"""
|
||||||
|
# `upload_path` is the resolved Path, `upload_dir` the bare setting: the
|
||||||
|
# rest of the codebase uses the former (see production_export_service).
|
||||||
|
name = f"{uuid4().hex}.{extension}"
|
||||||
|
relative_path = str(Path("vision") / "reference" / str(task_id) / name)
|
||||||
|
|
||||||
|
reference = VisionReferenceImage(
|
||||||
|
task_id=task_id,
|
||||||
|
path=relative_path,
|
||||||
|
note=note,
|
||||||
|
station_id=station_id,
|
||||||
|
device_code=device_code,
|
||||||
|
calibration_snapshot=calibration_snapshot,
|
||||||
|
)
|
||||||
|
db.add(reference)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
folder = _reference_file_path(reference).parent
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
_reference_file_path(reference).write_bytes(image_bytes)
|
||||||
|
|
||||||
|
await db.refresh(reference)
|
||||||
|
return reference
|
||||||
|
|
||||||
|
|
||||||
|
async def list_reference_images(
|
||||||
|
db: AsyncSession, task_id: int,
|
||||||
|
) -> list[VisionReferenceImage]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(VisionReferenceImage)
|
||||||
|
.where(VisionReferenceImage.task_id == task_id)
|
||||||
|
.order_by(VisionReferenceImage.acquired_at)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_reference_image(
|
||||||
|
db: AsyncSession, reference_id: int,
|
||||||
|
) -> VisionReferenceImage:
|
||||||
|
"""Flush the row's deletion. The caller (router) removes the file only
|
||||||
|
once the commit that makes the deletion durable has actually succeeded -
|
||||||
|
deleting it any earlier would leave the row pointing at a file that is
|
||||||
|
already gone if the commit then failed.
|
||||||
|
"""
|
||||||
|
reference = (await db.execute(
|
||||||
|
select(VisionReferenceImage).where(VisionReferenceImage.id == reference_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if reference is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Reference image not found",
|
||||||
|
)
|
||||||
|
await db.delete(reference)
|
||||||
|
await db.flush()
|
||||||
|
return reference
|
||||||
|
|
||||||
|
|
||||||
|
async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict:
|
||||||
|
"""Run the graph and save nothing. Composing is not measuring."""
|
||||||
|
if not task.vision_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="this task carries no vision graph",
|
||||||
|
)
|
||||||
|
return await _call_worker(
|
||||||
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
|
)
|
||||||
@@ -40,7 +40,7 @@ from src.backend.models.orm.measurement import Measurement
|
|||||||
from src.backend.models.orm.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from src.backend.services.auth_service import hash_password, generate_api_key
|
from src.backend.services.auth_service import hash_password, issue_api_key
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# In-memory SQLite engine for tests
|
# In-memory SQLite engine for tests
|
||||||
@@ -142,7 +142,6 @@ async def _create_user(
|
|||||||
active: bool = True,
|
active: bool = True,
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Insert a user into the test DB and return it with an API key set."""
|
"""Insert a user into the test DB and return it with an API key set."""
|
||||||
api_key = generate_api_key()
|
|
||||||
user = User(
|
user = User(
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
@@ -150,13 +149,14 @@ async def _create_user(
|
|||||||
roles=roles or [],
|
roles=roles or [],
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
active=active,
|
active=active,
|
||||||
api_key=api_key,
|
|
||||||
language_pref="en",
|
language_pref="en",
|
||||||
theme_pref="light",
|
theme_pref="light",
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
|
# Clear key of this test session; the DB only keeps its hash.
|
||||||
|
user.plain_key = await issue_api_key(session, user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -224,6 +224,10 @@ async def create_test_recipe(
|
|||||||
code=code,
|
code=code,
|
||||||
name=name,
|
name=name,
|
||||||
description="A recipe for testing",
|
description="A recipe for testing",
|
||||||
|
# Typing is allowed here so that tests about something else can still save
|
||||||
|
# a measurement. The rule itself is exercised by test_recipe_rules.py, on
|
||||||
|
# recipes that declare it explicitly.
|
||||||
|
allow_manual_input=True,
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
)
|
)
|
||||||
session.add(recipe)
|
session.add(recipe)
|
||||||
@@ -243,6 +247,10 @@ async def create_test_recipe(
|
|||||||
version_id=version.id,
|
version_id=version.id,
|
||||||
order_index=0,
|
order_index=0,
|
||||||
title="Test Task",
|
title="Test Task",
|
||||||
|
# Declared, like any task since the type stopped being deduced: this one has
|
||||||
|
# a quote under it, so a fixture that called it a note would be modelling
|
||||||
|
# exactly the inconsistency point 2 removed.
|
||||||
|
task_type="measure",
|
||||||
directive="Measure the part",
|
directive="Measure the part",
|
||||||
description="First measurement task",
|
description="First measurement task",
|
||||||
)
|
)
|
||||||
@@ -270,4 +278,4 @@ async def create_test_recipe(
|
|||||||
|
|
||||||
def auth_headers(user: User) -> dict[str, str]:
|
def auth_headers(user: User) -> dict[str, str]:
|
||||||
"""Return headers dict with the user's API key for authenticated requests."""
|
"""Return headers dict with the user's API key for authenticated requests."""
|
||||||
return {"X-API-Key": user.api_key}
|
return {"X-API-Key": user.plain_key}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Unit tests for AccessLogMiddleware (gitea#36 point 6).
|
||||||
|
|
||||||
|
- action must be truncated to fit AccessLog.action (String(100)): an
|
||||||
|
untruncated long path used to fail the INSERT silently (swallowed by the
|
||||||
|
broad except in dispatch()).
|
||||||
|
- ip_address must come from the same `_client_ip` the rate limiter uses
|
||||||
|
(proxy-aware), not the raw `request.client.host`, which behind
|
||||||
|
Nginx/Flask is always localhost.
|
||||||
|
|
||||||
|
These stub out async_session_factory and inspect the compiled INSERT
|
||||||
|
statement directly, rather than going through a real DB round trip: Core
|
||||||
|
`insert()` against the test's in-memory SQLite has an unrelated
|
||||||
|
autoincrement quirk (unrelated to this fix) that makes end-to-end
|
||||||
|
assertions on the persisted row unreliable here.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
"""Records the statement passed to execute(); no real DB involved."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.executed_stmt = None
|
||||||
|
|
||||||
|
async def execute(self, stmt):
|
||||||
|
self.executed_stmt = stmt
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(path: str, extra_headers: list[tuple[bytes, bytes]] | None = None) -> Request:
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": path,
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": extra_headers or [],
|
||||||
|
"client": ("127.0.0.1", 12345),
|
||||||
|
}
|
||||||
|
return Request(scope)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_long_path_action_is_truncated():
|
||||||
|
fake_session = _FakeSession()
|
||||||
|
long_path = "/api/files/" + "a" * 200
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.backend.api.middleware.logging.async_session_factory",
|
||||||
|
return_value=fake_session,
|
||||||
|
):
|
||||||
|
middleware = AccessLogMiddleware(app=None)
|
||||||
|
request = _make_request(long_path)
|
||||||
|
|
||||||
|
async def call_next(_req):
|
||||||
|
return Response(status_code=404)
|
||||||
|
|
||||||
|
await middleware.dispatch(request, call_next)
|
||||||
|
|
||||||
|
assert fake_session.executed_stmt is not None
|
||||||
|
params = fake_session.executed_stmt.compile().params
|
||||||
|
assert len(params["action"]) <= 100
|
||||||
|
assert params["action"] == f"GET {long_path}"[:100]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_short_path_action_is_unaffected():
|
||||||
|
fake_session = _FakeSession()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.backend.api.middleware.logging.async_session_factory",
|
||||||
|
return_value=fake_session,
|
||||||
|
):
|
||||||
|
middleware = AccessLogMiddleware(app=None)
|
||||||
|
request = _make_request("/api/settings/")
|
||||||
|
|
||||||
|
async def call_next(_req):
|
||||||
|
return Response(status_code=200)
|
||||||
|
|
||||||
|
await middleware.dispatch(request, call_next)
|
||||||
|
|
||||||
|
params = fake_session.executed_stmt.compile().params
|
||||||
|
assert params["action"] == "GET /api/settings/"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ip_address_uses_client_ip_resolution():
|
||||||
|
"""trusted_proxy_count defaults to 1: the last XFF hop (our own proxy's
|
||||||
|
add) is trusted, not request.client.host (always the proxy itself)."""
|
||||||
|
fake_session = _FakeSession()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"src.backend.api.middleware.logging.async_session_factory",
|
||||||
|
return_value=fake_session,
|
||||||
|
):
|
||||||
|
middleware = AccessLogMiddleware(app=None)
|
||||||
|
request = _make_request(
|
||||||
|
"/api/settings/",
|
||||||
|
extra_headers=[(b"x-forwarded-for", b"203.0.113.7, 10.0.0.1")],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def call_next(_req):
|
||||||
|
return Response(status_code=200)
|
||||||
|
|
||||||
|
await middleware.dispatch(request, call_next)
|
||||||
|
|
||||||
|
params = fake_session.executed_stmt.compile().params
|
||||||
|
assert params["ip_address"] == "10.0.0.1"
|
||||||
|
assert params["ip_address"] != "127.0.0.1" # not request.client.host
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tests for ai_service.py error handling (gitea#36 point 7).
|
||||||
|
|
||||||
|
A malformed response from the AI model (missing fields, invalid JSON) must
|
||||||
|
raise the dedicated AIResponseError, not a bare KeyError/JSONDecodeError —
|
||||||
|
JSONDecodeError in particular is itself a ValueError subclass, so without
|
||||||
|
this it was caught by the router's `except ValueError` (meant for our own
|
||||||
|
config errors) and misreported as a 400 instead of a 502.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.backend.services import ai_service
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, json_data):
|
||||||
|
self._json_data = json_data
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._json_data
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_openrouter(monkeypatch, response_json: dict):
|
||||||
|
monkeypatch.setattr(ai_service, "extract_text_from_pdf", lambda _b: "some extracted text")
|
||||||
|
monkeypatch.setattr(ai_service.settings, "openrouter_api_key", "test-key")
|
||||||
|
monkeypatch.setattr(ai_service.settings, "openrouter_model", "test-model")
|
||||||
|
|
||||||
|
fake_client = AsyncMock()
|
||||||
|
fake_client.post = AsyncMock(return_value=_FakeResponse(response_json))
|
||||||
|
fake_client.__aenter__ = AsyncMock(return_value=fake_client)
|
||||||
|
fake_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
return patch("src.backend.services.ai_service.httpx.AsyncClient", return_value=fake_client)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_choices_key_raises_ai_response_error(monkeypatch):
|
||||||
|
with _patch_openrouter(monkeypatch, {"no_choices_here": True}):
|
||||||
|
with pytest.raises(ai_service.AIResponseError):
|
||||||
|
await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_json_content_raises_ai_response_error(monkeypatch):
|
||||||
|
bad_content = "not valid json {{{"
|
||||||
|
response_json = {"choices": [{"message": {"content": bad_content}}]}
|
||||||
|
with _patch_openrouter(monkeypatch, response_json):
|
||||||
|
with pytest.raises(ai_service.AIResponseError):
|
||||||
|
await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_valid_json_content_returns_tasks(monkeypatch):
|
||||||
|
tasks = [{"title": "MATERIALI", "directive": "Verificare", "description": "..."}]
|
||||||
|
response_json = {"choices": [{"message": {"content": json.dumps(tasks)}}]}
|
||||||
|
with _patch_openrouter(monkeypatch, response_json):
|
||||||
|
result = await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||||
|
assert result == tasks
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_extracted_text_returns_empty_list(monkeypatch):
|
||||||
|
"""No AI call at all when the PDF has no extractable text."""
|
||||||
|
monkeypatch.setattr(ai_service, "extract_text_from_pdf", lambda _b: " ")
|
||||||
|
result = await ai_service.parse_technical_sheet(b"%PDF-fake")
|
||||||
|
assert result == []
|
||||||
@@ -78,6 +78,136 @@ class TestLogin:
|
|||||||
assert len(api_key) >= 32
|
assert len(api_key) >= 32
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginKeyPerSession:
|
||||||
|
"""Each login issues its own key; none revokes another (#25, #41)."""
|
||||||
|
|
||||||
|
async def test_two_logins_same_user_get_distinct_valid_keys(
|
||||||
|
self, client: AsyncClient, db_session
|
||||||
|
):
|
||||||
|
await _create_user(db_session, username="twostations", password="TwoStations1")
|
||||||
|
creds = {"username": "twostations", "password": "TwoStations1"}
|
||||||
|
resp1 = await client.post("/api/auth/login", json=creds)
|
||||||
|
resp2 = await client.post("/api/auth/login", json=creds)
|
||||||
|
k1, k2 = resp1.json()["api_key"], resp2.json()["api_key"]
|
||||||
|
assert k1 != k2
|
||||||
|
for k in (k1, k2):
|
||||||
|
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
async def test_db_stores_hash_never_clear_key(
|
||||||
|
self, client: AsyncClient, db_session
|
||||||
|
):
|
||||||
|
from sqlalchemy import select
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import hash_api_key
|
||||||
|
|
||||||
|
await _create_user(db_session, username="hashed", password="HashedPass1")
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login", json={"username": "hashed", "password": "HashedPass1"}
|
||||||
|
)
|
||||||
|
key = resp.json()["api_key"]
|
||||||
|
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
|
||||||
|
assert hash_api_key(key) in hashes
|
||||||
|
assert key not in hashes
|
||||||
|
assert all(len(h) == 64 for h in hashes)
|
||||||
|
|
||||||
|
async def test_expired_key_gives_401(self, client: AsyncClient, db_session):
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import update
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import utcnow
|
||||||
|
|
||||||
|
user = await _create_user(db_session, username="expired", password="ExpiredPass1")
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
|
||||||
|
)
|
||||||
|
resp = await client.get("/api/auth/me", headers=auth_headers(user))
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "expired" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
async def test_use_renews_expiry_only_after_interval(
|
||||||
|
self, client: AsyncClient, db_session
|
||||||
|
):
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import utcnow
|
||||||
|
|
||||||
|
user = await _create_user(db_session, username="renew", password="RenewPass1")
|
||||||
|
headers = auth_headers(user)
|
||||||
|
old = utcnow() - timedelta(hours=10)
|
||||||
|
|
||||||
|
# Recent use: no write, expiry untouched.
|
||||||
|
recent = utcnow() - timedelta(minutes=1)
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(expires_at=old + timedelta(hours=24), last_used_at=recent)
|
||||||
|
)
|
||||||
|
await client.get("/api/auth/me", headers=headers)
|
||||||
|
row = (await db_session.execute(select(ApiKey))).scalar_one()
|
||||||
|
await db_session.refresh(row)
|
||||||
|
assert row.last_used_at == recent
|
||||||
|
|
||||||
|
# Stale last use: expiry is pushed forward.
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(last_used_at=utcnow() - timedelta(minutes=10))
|
||||||
|
)
|
||||||
|
before = utcnow()
|
||||||
|
resp = await client.get("/api/auth/me", headers=headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
await db_session.refresh(row)
|
||||||
|
assert row.expires_at >= before + timedelta(hours=23, minutes=59)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogout:
|
||||||
|
"""Logout deletes only this session's key (#41)."""
|
||||||
|
|
||||||
|
async def test_logout_removes_only_own_key(self, client: AsyncClient, db_session):
|
||||||
|
await _create_user(db_session, username="shared_logout", password="SharedPass1")
|
||||||
|
creds = {"username": "shared_logout", "password": "SharedPass1"}
|
||||||
|
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
|
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
|
resp = await client.post("/api/auth/logout", headers={"X-API-Key": k1})
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": k1})).status_code == 401
|
||||||
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": k2})).status_code == 200
|
||||||
|
|
||||||
|
async def test_logout_all_sessions_removes_every_key(
|
||||||
|
self, client: AsyncClient, db_session
|
||||||
|
):
|
||||||
|
await _create_user(db_session, username="shared_logout_all", password="SharedPass1")
|
||||||
|
creds = {"username": "shared_logout_all", "password": "SharedPass1"}
|
||||||
|
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
|
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/logout", params={"all_sessions": "true"},
|
||||||
|
headers={"X-API-Key": k1},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
for k in (k1, k2):
|
||||||
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": k})).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthenticateUserTiming:
|
||||||
|
"""authenticate_user pays the bcrypt cost even for an unknown username (#25)."""
|
||||||
|
|
||||||
|
async def test_unknown_username_still_returns_none(self, db_session):
|
||||||
|
from src.backend.services.auth_service import authenticate_user
|
||||||
|
|
||||||
|
result = await authenticate_user(db_session, "no-such-user-at-all", "whatever")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_dummy_hash_is_a_valid_bcrypt_hash(self):
|
||||||
|
from src.backend.services.auth_service import (
|
||||||
|
_DUMMY_PASSWORD_HASH,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert verify_password(
|
||||||
|
"tiemeasureflow-dummy-hash-for-timing", _DUMMY_PASSWORD_HASH
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestHealthAndAuth:
|
class TestHealthAndAuth:
|
||||||
"""Health check and auth requirement tests."""
|
"""Health check and auth requirement tests."""
|
||||||
|
|
||||||
@@ -94,3 +224,69 @@ class TestHealthAndAuth:
|
|||||||
resp = await client.get("/api/users")
|
resp = await client.get("/api/users")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
|
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRevokeAllKeys:
|
||||||
|
"""Password change, deactivation and regenerate invalidate every key (#41)."""
|
||||||
|
|
||||||
|
async def _two_keys(self, client, db_session, name):
|
||||||
|
user = await _create_user(db_session, username=name, password="KeysPass123")
|
||||||
|
creds = {"username": name, "password": "KeysPass123"}
|
||||||
|
keys = [
|
||||||
|
(await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
|
for _ in range(2)
|
||||||
|
]
|
||||||
|
return user, keys
|
||||||
|
|
||||||
|
async def _all_401(self, client, keys):
|
||||||
|
for k in keys:
|
||||||
|
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
async def test_password_change_invalidates_all(self, client, db_session, admin_user):
|
||||||
|
user, keys = await self._two_keys(client, db_session, "pwkeys")
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/users/{user.id}/password",
|
||||||
|
headers=auth_headers(admin_user), json={"password": "NewSecurePass1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
await self._all_401(client, keys)
|
||||||
|
|
||||||
|
async def test_deactivation_invalidates_all(self, client, db_session, admin_user):
|
||||||
|
user, keys = await self._two_keys(client, db_session, "offkeys")
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/users/{user.id}", headers=auth_headers(admin_user)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
await self._all_401(client, keys)
|
||||||
|
|
||||||
|
async def test_regenerate_invalidates_old_keys(self, client, db_session, admin_user):
|
||||||
|
user, keys = await self._two_keys(client, db_session, "regenkeys")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/users/{user.id}/regenerate-key", headers=auth_headers(admin_user)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
await self._all_401(client, keys)
|
||||||
|
new = resp.json()["api_key"]
|
||||||
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": new})).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpiredKeyCleanup:
|
||||||
|
async def test_expired_key_removed_at_next_login(self, client, db_session):
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import hash_api_key, utcnow
|
||||||
|
|
||||||
|
user = await _create_user(db_session, username="cleanup", password="CleanPass123")
|
||||||
|
old_hash = hash_api_key(user.plain_key)
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login", json={"username": "cleanup", "password": "CleanPass123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
|
||||||
|
assert old_hash not in hashes
|
||||||
|
assert len(hashes) == 1
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Tests for Settings' production-secret guard (issue #33, backend part)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.backend.config import Settings, _DEFAULT_DB_PASSWORD, _DEFAULT_SECRET_KEY
|
||||||
|
|
||||||
|
_REAL_SECRET_KEY = "a-real-generated-secret-key-not-the-default"
|
||||||
|
_REAL_DB_PASSWORD = "a-real-generated-db-password-not-the-default"
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides) -> Settings:
|
||||||
|
"""Build a Settings instance without reading the real .env file or
|
||||||
|
inheriting stray SERVER_SECRET_KEY/DB_PASSWORD from the shell env."""
|
||||||
|
overrides.setdefault("server_secret_key", _DEFAULT_SECRET_KEY)
|
||||||
|
overrides.setdefault("db_password", _DEFAULT_DB_PASSWORD)
|
||||||
|
return Settings(_env_file=None, **overrides)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductionSecretGuard:
|
||||||
|
def test_dev_env_allows_default_secrets(self):
|
||||||
|
s = _settings(app_env="dev")
|
||||||
|
assert s.server_secret_key == _DEFAULT_SECRET_KEY
|
||||||
|
assert s.db_password == _DEFAULT_DB_PASSWORD
|
||||||
|
|
||||||
|
def test_test_env_allows_default_secrets(self):
|
||||||
|
s = _settings(app_env="test")
|
||||||
|
assert s.server_secret_key == _DEFAULT_SECRET_KEY
|
||||||
|
|
||||||
|
def test_production_env_rejects_default_secret_key(self):
|
||||||
|
with pytest.raises(RuntimeError, match="SERVER_SECRET_KEY"):
|
||||||
|
_settings(app_env="production", db_password=_REAL_DB_PASSWORD)
|
||||||
|
|
||||||
|
def test_production_env_rejects_default_db_password(self):
|
||||||
|
with pytest.raises(RuntimeError, match="DB_PASSWORD"):
|
||||||
|
_settings(app_env="production", server_secret_key=_REAL_SECRET_KEY)
|
||||||
|
|
||||||
|
def test_production_env_accepts_real_secrets(self):
|
||||||
|
s = _settings(
|
||||||
|
app_env="production",
|
||||||
|
server_secret_key=_REAL_SECRET_KEY,
|
||||||
|
db_password=_REAL_DB_PASSWORD,
|
||||||
|
)
|
||||||
|
assert s.app_env == "production"
|
||||||
|
|
||||||
|
def test_module_level_settings_instance_starts_without_env(self):
|
||||||
|
"""The already-instantiated `settings` (imported at process start)
|
||||||
|
must not have raised - this is what "tests/dev start without .env"
|
||||||
|
requires: APP_ENV defaults to "dev", so the guard never fires here."""
|
||||||
|
from src.backend.config import settings
|
||||||
|
|
||||||
|
assert settings.app_env == "dev"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user