Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f541f4a810 | |||
| c8dd5eecfd | |||
| f7d6d008c7 | |||
| 837f3d1069 | |||
| f5a2affa0f | |||
| 7ef28b7a9f | |||
| 06b87a15b3 | |||
| 3b3ecceb2a | |||
| 3899d5ecd9 | |||
| f986c172c2 | |||
| e44ac3129f | |||
| 4e52095423 | |||
| 23bfc93f1c | |||
| 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 ---
|
||||
# DB_PASSWORD e DB_ROOT_PASSWORD (sezione Docker sotto): in produzione (docker-compose.yml)
|
||||
# sono obbligatorie, senza default — `docker compose config` si rifiuta se mancano.
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_NAME=tiemeasureflow
|
||||
@@ -10,12 +12,20 @@ DB_USER=tmflow
|
||||
DB_PASSWORD=change_me_in_production
|
||||
|
||||
# --- Server ---
|
||||
# APP_ENV: "dev" (default) o "test" fanno partire il server senza controlli;
|
||||
# qualunque altro valore (es. "production") fa scattare i rifiuti descritti
|
||||
# sotto per SERVER_SECRET_KEY e DB_PASSWORD ancora ai valori committati.
|
||||
APP_ENV=dev
|
||||
# SERVER_SECRET_KEY: in produzione obbligatoria, senza default, e il server rifiuta
|
||||
# di avviarsi se resta uguale al valore committato qui sotto.
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8000
|
||||
SERVER_SECRET_KEY=change-this-to-a-random-secret-key
|
||||
SERVER_CORS_ORIGINS=http://localhost:5000
|
||||
|
||||
# --- Client ---
|
||||
# CLIENT_SECRET_KEY: in produzione obbligatoria, senza default, e il client rifiuta
|
||||
# di avviarsi se resta uguale al valore committato qui sotto.
|
||||
CLIENT_HOST=0.0.0.0
|
||||
CLIENT_PORT=5000
|
||||
CLIENT_SECRET_KEY=change-this-to-another-random-secret-key
|
||||
@@ -24,6 +34,25 @@ API_SERVER_URL=http://localhost:8000
|
||||
# Each physical tablet/PC deployment must set this unique per-station value.
|
||||
# Leave empty only for a single-station all-in-one demo using ST-DEFAULT.
|
||||
STATION_CODE=ST-DEFAULT
|
||||
# Allow switching station from the URL (?station=CODE) during commissioning, so a
|
||||
# single PC can exercise several stations. Leave at 0 in production: on the shop
|
||||
# floor the station identity comes from the local install, and measuring against
|
||||
# another station's recipes would silently break traceability.
|
||||
STATION_SWITCH_ENABLED=0
|
||||
# Cookie di sessione "Secure": di default segue FLASK_DEBUG (sicuro salvo
|
||||
# debug). Impostare a false solo per l'installazione on-premise che serve
|
||||
# HTTP in LAN (#38.4) - un cookie Secure su un'origine non-HTTPS viene
|
||||
# scartato dal browser in silenzio e il login non regge mai.
|
||||
# SESSION_COOKIE_SECURE=false
|
||||
|
||||
# --- Vision ---
|
||||
# Internal address of the vision worker. Never exposed outside tmflow-net.
|
||||
VISION_WORKER_URL=http://vision:8100
|
||||
# The pinned VisionSuite commit, stamped into the vision image at build time
|
||||
# (Dockerfile.vision ARG). Required: `docker compose build vision` fails
|
||||
# immediately without it. Set with:
|
||||
# VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD)
|
||||
VISION_ENGINE_VERSION=
|
||||
|
||||
# --- File Storage ---
|
||||
# Resolved against the project root in src/backend/config.py.
|
||||
@@ -34,12 +63,40 @@ MAX_UPLOAD_SIZE_MB=50
|
||||
|
||||
# --- Setup Page ---
|
||||
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
|
||||
# Una volta creato un admin, /api/setup/{reset-db,seed,manage-user,
|
||||
# change-user-password,init-db} rispondono 403. true per un deploy demo/test
|
||||
# riprovisionato via script: non impostarlo su un'installazione reale.
|
||||
SETUP_ALLOW_AFTER_INIT=false
|
||||
|
||||
# --- AI (OpenRouter) ---
|
||||
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare
|
||||
|
||||
# --- Deploy (introdotte dalla revisione del 21/09/2026) ---
|
||||
# Dominio pubblico dell'installazione, usato nelle rule Traefik di
|
||||
# docker-compose.yml (produzione). Obbligatorio, senza default.
|
||||
APP_DOMAIN=
|
||||
# Quanti hop di reverse proxy fidati ci sono davanti al server (Nginx o
|
||||
# Traefik): serve a calcolare l'IP reale del tablet per il rate limiter.
|
||||
TRUSTED_PROXY_COUNT=1
|
||||
# Numero di worker Uvicorn del server. Il Dockerfile ne avvia 4 di default
|
||||
# indipendentemente da questa variabile; impostarla esplicitamente per farla
|
||||
# coincidere se si cambia il numero di worker nell'immagine.
|
||||
WEB_CONCURRENCY=1
|
||||
# Cookie di sessione Flask con flag Secure. true di default (richiede HTTPS,
|
||||
# come dietro Traefik in docker-compose.yml). Le installazioni on-premise su
|
||||
# rete LAN in HTTP semplice (docker-compose.dev.yml, Nginx senza SSL) la
|
||||
# impostano a false, altrimenti il browser non rimanda mai indietro il cookie.
|
||||
SESSION_COOKIE_SECURE=true
|
||||
# Timeout (secondi) di un'esecuzione del worker di visione.
|
||||
VISION_RUN_TIMEOUT_S=100
|
||||
# Quante esecuzioni di visione in parallelo accetta il worker.
|
||||
VISION_MAX_CONCURRENCY=2
|
||||
# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo).
|
||||
VISION_RECIPES_ROOT=/app/uploads
|
||||
|
||||
# --- Docker ---
|
||||
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
|
||||
DB_ROOT_PASSWORD=root_password_change_me
|
||||
NGINX_PORT=80
|
||||
NGINX_SSL_PORT=443
|
||||
|
||||
+9
-10
@@ -33,16 +33,12 @@ env/
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Uploads (server-side files, now at project root)
|
||||
uploads/images/*
|
||||
uploads/pdfs/*
|
||||
uploads/logos/*
|
||||
uploads/reports/*
|
||||
uploads/general/
|
||||
!uploads/images/.gitkeep
|
||||
!uploads/pdfs/.gitkeep
|
||||
!uploads/logos/.gitkeep
|
||||
!uploads/reports/.gitkeep
|
||||
# Uploads (server-side files, mounted as a volume in production): ignora tutto
|
||||
# sotto uploads/, comprese le sottocartelle create a runtime (vision reference
|
||||
# images, cartelle per ricetta/versione) — tieni solo i .gitkeep che mantengono
|
||||
# le cartelle vuote in git.
|
||||
uploads/*
|
||||
!uploads/**/.gitkeep
|
||||
|
||||
# TailwindCSS output
|
||||
src/frontend/flask_app/static/css/tailwind.css
|
||||
@@ -74,3 +70,6 @@ nul
|
||||
# Competitor analysis (local only)
|
||||
Concorrente/
|
||||
docker-compose.override.yml
|
||||
|
||||
# Subagent-driven development scratch: ledger, briefs, review packages
|
||||
.superpowers/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "vendor/visionsuite"]
|
||||
path = vendor/visionsuite
|
||||
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
|
||||
@@ -0,0 +1 @@
|
||||
*
|
||||
@@ -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.
|
||||
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/
|
||||
@@ -18,10 +18,13 @@ TieMeasureFlow/
|
||||
├── .python-version # 3.11
|
||||
├── Dockerfile # Backend (uv + uvicorn)
|
||||
├── Dockerfile.frontend # Frontend (uv + gunicorn + Tailwind + Babel)
|
||||
├── Dockerfile.vision # Worker di visione (uv, Python 3.13)
|
||||
├── docker-compose.dev.yml # Dev (Nginx)
|
||||
├── docker-compose.yml # Prod (Traefik + SSL)
|
||||
├── nginx/
|
||||
├── uploads/ # Volume montato in /app/uploads
|
||||
├── vendor/
|
||||
│ └── visionsuite/ # Submodule: libreria di visione (vs-core, vs-task, vs-measure, vs-pm2d)
|
||||
├── docs/
|
||||
└── src/
|
||||
├── backend/
|
||||
@@ -38,16 +41,18 @@ TieMeasureFlow/
|
||||
│ ├── migrations/ # Alembic
|
||||
│ ├── templates/ # Setup page
|
||||
│ └── tests/ # pytest
|
||||
└── frontend/
|
||||
└── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
||||
├── app.py # giustificata: tablet UX server-side, USB calipers,
|
||||
├── config.py # workflow operatore con Fabric.js)
|
||||
├── blueprints/
|
||||
├── services/
|
||||
├── templates/
|
||||
├── static/
|
||||
├── translations/
|
||||
└── tests/
|
||||
├── frontend/
|
||||
│ └── flask_app/ # Flask + Jinja2 + Alpine.js (deroga vs spec React,
|
||||
│ ├── app.py # giustificata: tablet UX server-side, USB calipers,
|
||||
│ ├── config.py # workflow operatore con Fabric.js)
|
||||
│ ├── blueprints/
|
||||
│ ├── services/
|
||||
│ ├── templates/
|
||||
│ ├── static/
|
||||
│ ├── translations/
|
||||
│ └── tests/
|
||||
├── vision/ # Runner che esegue i grafi di VisionSuite (Python 3.13)
|
||||
└── vision_worker/ # Shell FastAPI attorno al runner, container separato
|
||||
```
|
||||
|
||||
Dipendenze gestite con **uv** (no `requirements.txt`):
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
- Python 3.11+, type hints ovunque
|
||||
- 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.
|
||||
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.
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Non-root user the app runs as at runtime. -m creates a home dir: `uv run`
|
||||
# in the CMD below writes its cache under $HOME, and without one it has
|
||||
# nowhere writable to put it.
|
||||
RUN useradd -m -u 10001 app
|
||||
# Independent of $HOME regardless: keeps the cache path explicit and writable
|
||||
# by the app user even if the home directory assumption ever changes.
|
||||
ENV UV_CACHE_DIR=/tmp/uv-cache
|
||||
# `uv run` must use the venv built by `uv sync` below as it is: re-validating
|
||||
# the project would look for vendor/visionsuite (the vision-worker extra), which
|
||||
# this image does not carry (issue 39). `uv sync` itself is not affected by it.
|
||||
ENV UV_NO_SYNC=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfile + project metadata first to maximize Docker layer cache.
|
||||
@@ -27,7 +39,10 @@ RUN uv sync --frozen --no-dev --extra server
|
||||
COPY src/ ./src/
|
||||
|
||||
# Uploads directory (mounted as a volume in production).
|
||||
RUN mkdir -p /app/uploads/images /app/uploads/pdfs /app/uploads/logos /app/uploads/reports
|
||||
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
|
||||
|
||||
|
||||
+36
-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).
|
||||
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.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& 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
|
||||
|
||||
@@ -16,16 +37,17 @@ COPY pyproject.toml uv.lock ./
|
||||
COPY .python-version ./
|
||||
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/
|
||||
|
||||
# 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
|
||||
COPY --from=tailwind-build /app/flask_app/static/css/tailwind.css ./flask_app/static/css/tailwind.css
|
||||
|
||||
# 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
|
||||
|
||||
USER app
|
||||
|
||||
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.
|
||||
|
||||
> **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).
|
||||
> 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
|
||||
|
||||
```
|
||||
@@ -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 |
|
||||
| gunicorn | 21+ | WSGI server (5 workers × 4 thread gthread) |
|
||||
| Jinja2 | incluso in Flask | Template engine |
|
||||
| Alpine.js | 3.x (CDN) | Reattività leggera lato client |
|
||||
| TailwindCSS | 3.x | CSS utility-first |
|
||||
| Plotly.js | CDN | Grafici SPC interattivi |
|
||||
| Fabric.js | 5.3.1 (CDN) | Editor annotazioni disegni tecnici |
|
||||
| html5-qrcode | CDN | Scanner barcode/QR camera |
|
||||
| Alpine.js | 3.15.12 (locale) | Reattività leggera lato client |
|
||||
| TailwindCSS | 3.4.19 (build) | CSS utility-first, compilato nell'immagine |
|
||||
| Plotly.js | 2.32.0 (locale) | Grafici SPC interattivi |
|
||||
| PDF.js | 3.11.174 (locale) | Visualizzazione disegni PDF, worker incluso |
|
||||
| Fabric.js | 5.3.1 (locale) | Editor annotazioni disegni tecnici |
|
||||
| Inter + JetBrains Mono | woff2 locali | Font UI e numeri |
|
||||
| Flask-Babel | ultima stabile | i18n IT/EN |
|
||||
|
||||
**Nessuna libreria arriva dalla rete.** Tutte stanno in
|
||||
[`src/frontend/flask_app/static/vendor/`](src/frontend/flask_app/static/vendor/VERSIONS.md)
|
||||
con versione nel nome e impronta SHA-256 verificata da un test: l'installazione a
|
||||
Tràfilo è su rete di produzione isolata, dove una pagina che aspetta un CDN è una
|
||||
pagina bianca. Una Content-Security-Policy a sola origine locale (`app.py`) fa sì che
|
||||
un tag verso l'esterno aggiunto in futuro venga rifiutato alla scrivania, non in
|
||||
reparto.
|
||||
|
||||
### Tooling
|
||||
|
||||
| Componente | Ruolo |
|
||||
@@ -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.
|
||||
|
||||
```bash
|
||||
# 1. Clona il repository
|
||||
git clone ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
||||
# 1. Clona il repository CON il sottomodulo di VisionSuite (vendor/visionsuite):
|
||||
# senza --recurse-submodules la cartella resta vuota e la build del worker di
|
||||
# visione fallisce. Il sottomodulo è su ssh://git.tielogic.xyz:222: serve una
|
||||
# chiave SSH autorizzata su quel repo, sia sulla macchina di sviluppo sia sulla
|
||||
# VPS che farà la build.
|
||||
git clone --recurse-submodules ssh://git@git.tielogic.xyz:222/Adriano/TieMeasureFlow.git
|
||||
cd TieMeasureFlow
|
||||
# Chi ha già clonato senza il sottomodulo:
|
||||
# git submodule update --init
|
||||
|
||||
# 2. Configura le variabili d'ambiente
|
||||
cp .env.example .env
|
||||
# Modifica .env: credenziali DB, chiavi segrete, SETUP_PASSWORD, STATION_CODE per ogni tablet
|
||||
|
||||
# 3. Avvia i servizi (ambiente di sviluppo)
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
# 3. Valorizza VISION_ENGINE_VERSION PRIMA della build: il worker di visione
|
||||
# (Dockerfile.vision) rifiuta di costruirsi senza — vedi "Il worker di
|
||||
# visione" più sotto per il motivo. Poi avvia i servizi (ambiente di sviluppo).
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
# 4. Verifica lo stato dei container
|
||||
docker compose -f docker-compose.dev.yml ps
|
||||
@@ -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 |
|
||||
| **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** |
|
||||
|
||||
---
|
||||
@@ -229,6 +275,7 @@ TieMeasureFlow/
|
||||
├── docker-compose.yml # Produzione (Traefik, SSL)
|
||||
├── nginx/ # Config Nginx (dev)
|
||||
├── uploads/ # Volume Docker file caricati
|
||||
├── scripts/ # Script del progetto (seed ricette di collaudo)
|
||||
├── docs/ # Documentazione (vedi indice docs/README.md)
|
||||
└── src/
|
||||
├── backend/ # FastAPI Backend
|
||||
@@ -245,11 +292,14 @@ TieMeasureFlow/
|
||||
│ │ │ # RecipeTask, RecipeSubtask, Measurement,
|
||||
│ │ │ # AccessLog, SystemSetting,
|
||||
│ │ │ # RecipeVersionAudit, Station,
|
||||
│ │ │ # StationRecipeAssignment
|
||||
│ │ │ # StationRecipeAssignment,
|
||||
│ │ │ # ProductionRun, ProductionEvent
|
||||
│ │ └── api/ # Pydantic v2 schemas request/response
|
||||
│ ├── services/ # recipe_service, measurement_service,
|
||||
│ │ # spc_service, report_service,
|
||||
│ │ # auth_service, station_service
|
||||
│ │ # auth_service, station_service,
|
||||
│ │ # production_service,
|
||||
│ │ # production_export_service
|
||||
│ ├── migrations/ # Alembic (alembic.ini + env.py)
|
||||
│ ├── templates/ # Pagina setup (Jinja2)
|
||||
│ └── tests/ # pytest + httpx + aiosqlite
|
||||
@@ -262,9 +312,13 @@ TieMeasureFlow/
|
||||
├── services/ # APIClient (proxy verso FastAPI con XFF)
|
||||
├── templates/ # Jinja2 + Alpine.js
|
||||
├── static/
|
||||
│ ├── css/ # TailwindCSS compilato
|
||||
│ └── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ # spc-charts, annotation-editor/viewer
|
||||
│ ├── css/ # TailwindCSS compilato + themes.css
|
||||
│ │ # (cornice pagina, temi, scrollbar)
|
||||
│ ├── js/ # numpad, caliper, barcode, csv-export,
|
||||
│ │ # spc-charts, annotation-editor/viewer,
|
||||
│ │ # production-clock, rich-text
|
||||
│ └── vendor/ # Alpine, Plotly, PDF.js (+worker), Fabric,
|
||||
│ # font woff2 — vedi VERSIONS.md
|
||||
├── translations/ # Flask-Babel .po/.mo IT/EN
|
||||
└── tests/
|
||||
```
|
||||
@@ -341,7 +395,98 @@ uv run pytest --cov src/backend
|
||||
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 +504,7 @@ Copia `.env.example` in `.env` e configura:
|
||||
| `CLIENT_SECRET_KEY` | Chiave segreta Flask (sessioni, CSRF) |
|
||||
| `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. |
|
||||
| `VISION_WORKER_URL` | Indirizzo interno del worker di visione (default: `http://vision:8100`, mai esposto fuori da `tmflow-net`) |
|
||||
| `UPLOAD_DIR` | Percorso upload file (default: `uploads`, project root) |
|
||||
| `MAX_UPLOAD_SIZE_MB` | Limite dimensione upload (default 50) |
|
||||
| `RATE_LIMIT_LOGIN` | Login req/min/IP (default 5) |
|
||||
@@ -377,6 +523,7 @@ Indice completo: [`docs/README.md`](docs/README.md).
|
||||
|
||||
| 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/ROADMAP.md`](docs/architecture/ROADMAP.md) | Cosa resta da fare (Fasi 2-7 rev04, decisioni cliente aperte, stime) |
|
||||
|
||||
@@ -388,6 +535,8 @@ Indice completo: [`docs/README.md`](docs/README.md).
|
||||
| [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) | Guida deployment VPS: Docker, Traefik, SSL, DNS, firewall |
|
||||
| [`docs/USER_GUIDE.md`](docs/USER_GUIDE.md) | Manuale utente per ruolo (Maker, MeasurementTec, Metrologist) |
|
||||
| [`docs/I18N_SETUP.md`](docs/I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js) |
|
||||
| [`docs/architecture/LAYOUT.md`](docs/architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava e la regola che lo tiene fermo |
|
||||
| [`src/frontend/flask_app/static/vendor/VERSIONS.md`](src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte, come aggiornarle |
|
||||
|
||||
### Piani dettagliati
|
||||
|
||||
|
||||
@@ -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.
|
||||
+46
-3
@@ -14,6 +14,7 @@ services:
|
||||
--authentication-policy=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--default-time-zone=+00:00
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
@@ -31,13 +32,42 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
APP_ENV: dev
|
||||
DB_HOST: mysql
|
||||
UPLOAD_DIR: uploads
|
||||
VISION_WORKER_URL: http://vision:8100
|
||||
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
|
||||
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1}
|
||||
volumes:
|
||||
- upload_data:/app/uploads
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
vision:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.vision
|
||||
args:
|
||||
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||
container_name: tmflow-vision
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- upload_data:/app/uploads:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
@@ -51,8 +81,19 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
API_SERVER_URL: http://server:8000
|
||||
# Nginx qui non termina SSL (vedi nginx/nginx.conf): un'installazione
|
||||
# on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure,
|
||||
# altrimenti il browser non lo rimanda mai indietro.
|
||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||
depends_on:
|
||||
- server
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
@@ -69,8 +110,10 @@ services:
|
||||
# - ./certbot/conf:/etc/letsencrypt:ro
|
||||
# - ./certbot/www:/var/www/certbot:ro
|
||||
depends_on:
|
||||
- server
|
||||
- client
|
||||
server:
|
||||
condition: service_healthy
|
||||
client:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
|
||||
+52
-8
@@ -4,16 +4,17 @@ services:
|
||||
container_name: tmflow-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root_password_change_me}
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?manca DB_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME:-tiemeasureflow}
|
||||
MYSQL_USER: ${DB_USER:-tmflow}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-change_me_in_production}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:?manca DB_PASSWORD}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
command: >
|
||||
--authentication-policy=mysql_native_password
|
||||
--character-set-server=utf8mb4
|
||||
--collation-server=utf8mb4_unicode_ci
|
||||
--default-time-zone=+00:00
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
interval: 10s
|
||||
@@ -31,25 +32,59 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
APP_ENV: production
|
||||
DB_HOST: mysql
|
||||
UPLOAD_DIR: uploads
|
||||
VISION_WORKER_URL: http://vision:8100
|
||||
SERVER_SECRET_KEY: ${SERVER_SECRET_KEY:?manca SERVER_SECRET_KEY}
|
||||
WEB_CONCURRENCY: ${WEB_CONCURRENCY:-4}
|
||||
TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1}
|
||||
volumes:
|
||||
- upload_data:/app/uploads
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.tmflow-api.rule=Host(`tieflow.tielogic.xyz`) && (PathPrefix(`/api/`) || PathPrefix(`/uploads/`))"
|
||||
- "traefik.http.routers.tmflow-api.rule=Host(`${APP_DOMAIN:?manca APP_DOMAIN}`) && PathPrefix(`/api/`)"
|
||||
- "traefik.http.routers.tmflow-api.entrypoints=web,websecure"
|
||||
- "traefik.http.routers.tmflow-api.tls=true"
|
||||
- "traefik.http.routers.tmflow-api.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.tmflow-api.priority=100"
|
||||
- "traefik.http.services.tmflow-api.loadbalancer.server.port=8000"
|
||||
- "traefik.http.routers.tmflow-api.middlewares=tmflow-headers"
|
||||
networks:
|
||||
- tmflow-net
|
||||
- traefik-net
|
||||
|
||||
vision:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.vision
|
||||
args:
|
||||
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||
container_name: tmflow-vision
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# #46: radice delle cartelle ricetta (volume uploads, sola lettura)
|
||||
VISION_RECIPES_ROOT: /app/uploads
|
||||
volumes:
|
||||
- upload_data:/app/uploads:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8100/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
client:
|
||||
build:
|
||||
context: .
|
||||
@@ -60,23 +95,32 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
API_SERVER_URL: http://server:8000
|
||||
CLIENT_SECRET_KEY: ${CLIENT_SECRET_KEY:?manca CLIENT_SECRET_KEY}
|
||||
depends_on:
|
||||
- server
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/auth/login', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.tmflow-web.rule=Host(`tieflow.tielogic.xyz`)"
|
||||
- "traefik.http.routers.tmflow-web.rule=Host(`${APP_DOMAIN:?manca APP_DOMAIN}`)"
|
||||
- "traefik.http.routers.tmflow-web.entrypoints=web,websecure"
|
||||
- "traefik.http.routers.tmflow-web.tls=true"
|
||||
- "traefik.http.routers.tmflow-web.tls.certresolver=mytlschallenge"
|
||||
- "traefik.http.routers.tmflow-web.priority=10"
|
||||
- "traefik.http.services.tmflow-web.loadbalancer.server.port=5000"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.SSLRedirect=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.STSSeconds=315360000"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.browserXSSFilter=true"
|
||||
# Traefik v3: SSLRedirect e browserXSSFilter non ci sono più (SSL redirect
|
||||
# si fa a livello di entrypoint, l'XSS Auditor del browser non esiste più).
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.stsSeconds=315360000"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.contentTypeNosniff=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.forceSTSHeader=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.STSIncludeSubdomains=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.STSPreload=true"
|
||||
- "traefik.http.middlewares.tmflow-headers.headers.customFrameOptionsValue=SAMEORIGIN"
|
||||
- "traefik.http.routers.tmflow-web.middlewares=tmflow-headers"
|
||||
networks:
|
||||
- tmflow-net
|
||||
|
||||
@@ -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_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
|
||||
@@ -169,63 +182,33 @@ EXIT;
|
||||
|
||||
### 2. Run Database Migrations
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
This creates all required tables:
|
||||
- `users` - System users
|
||||
- `recipes` - Measurement recipes
|
||||
- `recipe_versions` - Immutable recipe versions
|
||||
- `recipe_tasks` - Tasks within recipes
|
||||
- `recipe_subtasks` - Subtasks within tasks (individual measurements)
|
||||
- `measurements` - Recorded measurements
|
||||
- `access_logs` - API access audit trail
|
||||
- `system_settings` - Configuration key-value pairs
|
||||
- `recipe_version_audit` - Recipe change history
|
||||
|
||||
### 3. Create Initial Admin User (Optional)
|
||||
|
||||
Use the Flask client to create the first admin user, or run:
|
||||
The monorepo is gestito con `uv`, non con `requirements.txt` per sottocartella: si esegue dalla radice del repository, con il flag `-c` perché `alembic.ini` vive in `src/backend/migrations/`.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -c "
|
||||
from database import init_db, SessionLocal
|
||||
from services.auth_service import create_user
|
||||
import asyncio
|
||||
|
||||
async def init():
|
||||
await init_db()
|
||||
async with SessionLocal() as db:
|
||||
await create_user(
|
||||
db,
|
||||
username='admin',
|
||||
password='change_me_first_login',
|
||||
display_name='Admin',
|
||||
email='admin@example.com',
|
||||
roles=['Maker', 'MeasurementTec', 'Metrologist'],
|
||||
is_admin=True
|
||||
)
|
||||
await db.commit()
|
||||
print('Admin user created: admin / change_me_first_login')
|
||||
|
||||
asyncio.run(init())
|
||||
"
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
Questo è lo stesso comando che il container del server esegue da solo a ogni avvio (`CMD` del `Dockerfile`, prima di `uvicorn`): un deployment Docker non richiede questo passo a mano. La prima migrazione crea lo schema base anche su un database completamente vuoto — non serve più creare le tabelle a parte prima di lanciare `upgrade head`.
|
||||
|
||||
### 3. Create Initial Admin User
|
||||
|
||||
Non c'è più uno script Python da lanciare a mano: il primo utente amministratore si crea dalla pagina `/api/setup` (protetta da `SETUP_PASSWORD`, vedi sopra), che inizializza il database, crea l'admin con le credenziali fornite nel form e, opzionalmente, carica i dati demo — vedi [`README.md`](../README.md#setup-iniziale).
|
||||
|
||||
Una volta creato il primo amministratore, l'endpoint di setup si disattiva da solo: le richieste successive di inizializzazione o creazione utenti ricevono `403 Forbidden`, anche con `SETUP_PASSWORD` corretta. Per rieseguirlo — ad esempio per riconfigurare una stazione da zero — si imposta `SETUP_ALLOW_AFTER_INIT=true` nell'ambiente del server, si esegue l'operazione, e si toglie la variabile: non è pensata per restare attiva in modo permanente.
|
||||
|
||||
---
|
||||
|
||||
## Server Deployment
|
||||
|
||||
Docker Compose (sezione successiva) è il metodo raccomandato e quello che gira
|
||||
in produzione. Questa sezione descrive l'avvio **senza Docker**, utile per
|
||||
sviluppare sul runner senza ricostruire un'immagine a ogni modifica.
|
||||
|
||||
### Development Server
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
uv sync --extra server --extra dev
|
||||
uv run uvicorn src.backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Runs at `http://0.0.0.0:8000`
|
||||
@@ -234,40 +217,15 @@ API Documentation:
|
||||
- Swagger UI: `http://localhost:8000/docs`
|
||||
- ReDoc: `http://localhost:8000/redoc`
|
||||
|
||||
### Production Server (Gunicorn + Uvicorn)
|
||||
### Production, senza Docker
|
||||
|
||||
Install Gunicorn:
|
||||
Lo stesso comando che gira dentro il container (`Dockerfile`), lanciato dalla
|
||||
radice del repository con l'interprete e il venv pinnati da `uv`:
|
||||
|
||||
```bash
|
||||
pip install gunicorn
|
||||
```
|
||||
|
||||
Start server:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
gunicorn main:app \
|
||||
--workers 4 \
|
||||
--worker-class uvicorn.workers.UvicornWorker \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--access-logfile /var/log/tiemeasureflow/access.log \
|
||||
--error-logfile /var/log/tiemeasureflow/error.log \
|
||||
--log-level info
|
||||
```
|
||||
|
||||
### Production Server (Waitress - Windows)
|
||||
|
||||
Install Waitress:
|
||||
|
||||
```bash
|
||||
pip install waitress
|
||||
```
|
||||
|
||||
Start server:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
waitress-serve --host=0.0.0.0 --port=8000 main:app
|
||||
uv run uvicorn src.backend.main:app \
|
||||
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY:-4} \
|
||||
--proxy-headers --forwarded-allow-ips='*'
|
||||
```
|
||||
|
||||
### systemd Service (Linux)
|
||||
@@ -280,14 +238,14 @@ Description=TieMeasureFlow API Server
|
||||
After=network.target mysql.service
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
Type=simple
|
||||
User=tiemeasureflow
|
||||
WorkingDirectory=/opt/tiemeasureflow/server
|
||||
Environment="PATH=/opt/tiemeasureflow/venv/bin"
|
||||
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn main:app \
|
||||
--workers 4 \
|
||||
--worker-class uvicorn.workers.UvicornWorker \
|
||||
--bind 0.0.0.0:8000
|
||||
WorkingDirectory=/opt/tiemeasureflow
|
||||
EnvironmentFile=/opt/tiemeasureflow/.env
|
||||
Environment="WEB_CONCURRENCY=4"
|
||||
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run uvicorn src.backend.main:app \
|
||||
--host 0.0.0.0 --port 8000 --workers ${WEB_CONCURRENCY} \
|
||||
--proxy-headers --forwarded-allow-ips='*'
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
@@ -315,12 +273,14 @@ sudo journalctl -u tiemeasureflow-api -f
|
||||
|
||||
## Client Deployment
|
||||
|
||||
Anche qui, Docker Compose è il metodo raccomandato; questa sezione è per lo
|
||||
sviluppo o un'installazione manuale senza container.
|
||||
|
||||
### Development Server
|
||||
|
||||
```bash
|
||||
cd client
|
||||
pip install -r requirements.txt
|
||||
flask run --host 0.0.0.0 --port 5000
|
||||
uv sync --extra client --extra dev
|
||||
cd src/frontend/flask_app && uv run --project ../../.. flask run --host 0.0.0.0 --port 5000
|
||||
```
|
||||
|
||||
Runs at `http://0.0.0.0:5000`
|
||||
@@ -328,16 +288,20 @@ Runs at `http://0.0.0.0:5000`
|
||||
### Compile Tailwind CSS (Optional)
|
||||
|
||||
```bash
|
||||
cd client
|
||||
cd src/frontend/flask_app
|
||||
npx tailwindcss -i static/css/input.css -o static/css/tailwind.css --watch
|
||||
```
|
||||
|
||||
### Production Server (Gunicorn)
|
||||
### Production, senza Docker
|
||||
|
||||
Lo stesso comando del `Dockerfile.frontend` (gunicorn richiede la cwd dentro
|
||||
`flask_app` per risolvere `app:create_app()`):
|
||||
|
||||
```bash
|
||||
pip install gunicorn
|
||||
cd client
|
||||
gunicorn --workers 4 --bind 0.0.0.0:5000 app:app
|
||||
cd src/frontend/flask_app && uv run --project ../../.. gunicorn \
|
||||
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
|
||||
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
|
||||
"app:create_app()"
|
||||
```
|
||||
|
||||
### systemd Service (Linux)
|
||||
@@ -350,15 +314,14 @@ Description=TieMeasureFlow Web Client
|
||||
After=network.target tiemeasureflow-api.service
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
Type=simple
|
||||
User=tiemeasureflow
|
||||
WorkingDirectory=/opt/tiemeasureflow/client
|
||||
Environment="PATH=/opt/tiemeasureflow/venv/bin"
|
||||
Environment="FLASK_ENV=production"
|
||||
ExecStart=/opt/tiemeasureflow/venv/bin/gunicorn \
|
||||
--workers 4 \
|
||||
--bind 0.0.0.0:5000 \
|
||||
app:app
|
||||
WorkingDirectory=/opt/tiemeasureflow/src/frontend/flask_app
|
||||
EnvironmentFile=/opt/tiemeasureflow/.env
|
||||
ExecStart=/opt/tiemeasureflow/.venv/bin/uv run --project /opt/tiemeasureflow gunicorn \
|
||||
--workers 5 --threads 4 --worker-class gthread --timeout 60 \
|
||||
--bind 0.0.0.0:5000 --forwarded-allow-ips='*' \
|
||||
"app:create_app()"
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
@@ -373,8 +336,9 @@ WantedBy=multi-user.target
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- [ ] Change all default passwords in `.env`
|
||||
- [ ] Change all default passwords in `.env` — con `docker-compose.yml`, `DB_ROOT_PASSWORD`, `DB_PASSWORD`, `SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY` non hanno più un default: `docker compose config` si rifiuta di partire se mancano (introdotto dalla revisione del 21/09/2026)
|
||||
- [ ] Generate random `SERVER_SECRET_KEY` (32+ characters)
|
||||
- [ ] Set `APP_DOMAIN` to the real public domain (used by the Traefik router rules)
|
||||
- [ ] Set CORS origins to actual client domains
|
||||
- [ ] Enable SSL/HTTPS (see SSL/TLS section)
|
||||
- [ ] Configure firewall rules
|
||||
@@ -386,200 +350,140 @@ WantedBy=multi-user.target
|
||||
|
||||
### Recommended Architecture
|
||||
|
||||
Il progetto usa due reverse proxy diversi a seconda dell'installazione, non uno
|
||||
generico da configurare a mano:
|
||||
|
||||
- **On-premise, rete LAN, HTTP semplice** (`docker-compose.dev.yml`): Nginx,
|
||||
configurazione già pronta in [`nginx/nginx.conf`](../nginx/nginx.conf). È il
|
||||
caso di una stazione di misura sul reparto, senza certificato TLS.
|
||||
- **Produzione, dominio pubblico** (`docker-compose.yml`): Traefik, con
|
||||
certificati Let's Encrypt automatici. La configurazione sta nelle label
|
||||
Docker del servizio, non in un file a parte — vedi la sezione
|
||||
[Docker Deployment](#docker-deployment) sotto.
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Nginx Proxy │ (Port 80/443)
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
┌───▼──┐ ┌───▼──┐
|
||||
│API │ │Web │
|
||||
│:8000 │ │:5000 │
|
||||
└──────┘ └──────┘
|
||||
│ │
|
||||
└────┬─────┘
|
||||
│
|
||||
┌────▼────┐
|
||||
│ MySQL │
|
||||
│ :3306 │
|
||||
└─────────┘
|
||||
┌──────────────────────────┐
|
||||
On-premise (LAN) │ Nginx (:80) │
|
||||
└─────────────┬────────────┘
|
||||
│
|
||||
Produzione (dominio pubblico) → Traefik (:80/:443, TLS Let's Encrypt)
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
┌─────▼─────┐ ┌──────▼─────┐
|
||||
│ server │ │ client │
|
||||
│ :8000 │ │ :5000 │
|
||||
└─────┬──────┘ └────────────┘
|
||||
│
|
||||
┌─────▼──────┐ ┌─────────────┐
|
||||
│ vision │ │ MySQL 8.0 │
|
||||
│ :8100 │ │ :3306 │
|
||||
│ (interno) │ └─────────────┘
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
### Nginx Configuration
|
||||
|
||||
Create `/etc/nginx/sites-available/tiemeasureflow`:
|
||||
|
||||
```nginx
|
||||
upstream tiemeasureflow_api {
|
||||
server 127.0.0.1:8000;
|
||||
}
|
||||
|
||||
upstream tiemeasureflow_web {
|
||||
server 127.0.0.1:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name yourdomain.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/yourdomain.com.crt;
|
||||
ssl_certificate_key /etc/ssl/private/yourdomain.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
# API
|
||||
location /api/ {
|
||||
proxy_pass http://tiemeasureflow_api;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Web Client
|
||||
location / {
|
||||
proxy_pass http://tiemeasureflow_web;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Static files (optional caching)
|
||||
location /static/ {
|
||||
proxy_pass http://tiemeasureflow_web;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable and test:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/tiemeasureflow /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
Il worker di visione (`vision`) non pubblica porte verso l'esterno: lo
|
||||
raggiunge solo il `server`, sulla rete Docker interna `tmflow-net`.
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Docker Compose (Complete Stack)
|
||||
Docker Compose è il metodo raccomandato ed è quello usato in produzione. Il
|
||||
progetto ha **tre Dockerfile alla radice** del repository — non uno a
|
||||
sottocartella:
|
||||
|
||||
The project includes a `docker-compose.yml` for easy deployment:
|
||||
| File | Servizio | Base |
|
||||
|---|---|---|
|
||||
| `Dockerfile` | `server` (FastAPI) | `python:3.11.16-slim` |
|
||||
| `Dockerfile.frontend` | `client` (Flask) | `python:3.11.16-slim`, TailwindCSS compilato in uno stage `node:20.20.2-bookworm-slim` |
|
||||
| `Dockerfile.vision` | `vision` (worker di visione) | `python:3.13.15-slim` |
|
||||
|
||||
E due file compose:
|
||||
|
||||
- **`docker-compose.dev.yml`** — sviluppo/on-premise, con Nginx (vedi
|
||||
[Recommended Architecture](#recommended-architecture) sopra). 4 servizi:
|
||||
`mysql`, `server`, `vision`, `client`, `nginx`.
|
||||
- **`docker-compose.yml`** — produzione, con Traefik e TLS automatico. 4
|
||||
servizi: `mysql`, `server`, `vision`, `client`. Richiede la rete Docker
|
||||
esterna `traefik` (vedi `networks.traefik-net` nel file) e le variabili
|
||||
`APP_DOMAIN`, `DB_ROOT_PASSWORD`, `DB_PASSWORD`, `SERVER_SECRET_KEY`,
|
||||
`CLIENT_SECRET_KEY` valorizzate — senza default, `docker compose config` si
|
||||
rifiuta di procedere se mancano.
|
||||
|
||||
Il worker di visione dipende dal submodule `vendor/visionsuite`
|
||||
(`git clone --recurse-submodules`, vedi il [Quick Start del README](../README.md#quick-start-con-docker))
|
||||
e richiede `VISION_ENGINE_VERSION` valorizzata **prima** della build, altrimenti
|
||||
`Dockerfile.vision` fallisce di proposito:
|
||||
|
||||
```bash
|
||||
# Build images
|
||||
docker-compose build
|
||||
# Sviluppo / on-premise
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
# Produzione
|
||||
VISION_ENGINE_VERSION=$(git -C vendor/visionsuite rev-parse HEAD) \
|
||||
docker compose -f docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
### Manual Docker Build
|
||||
|
||||
#### API Server
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app/server
|
||||
|
||||
COPY server/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server/ .
|
||||
COPY .env ..
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
Build and run:
|
||||
Comandi comuni (sintassi `docker compose` v2, senza trattino — il plugin, non
|
||||
il vecchio `docker-compose` standalone):
|
||||
|
||||
```bash
|
||||
docker build -f server/Dockerfile -t tiemeasureflow-api .
|
||||
docker run -p 8000:8000 --env-file .env tiemeasureflow-api
|
||||
docker compose -f docker-compose.yml ps
|
||||
docker compose -f docker-compose.yml logs -f server
|
||||
docker compose -f docker-compose.yml down
|
||||
```
|
||||
|
||||
#### Web Client
|
||||
### Aggiornare un'installazione esistente
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
Le immagini precedenti creavano l'utente non privilegiato senza una home
|
||||
directory (`useradd -u 10001 app`), e il volume `upload_data` che ne è
|
||||
risultato appartiene a `root`. Le immagini attuali creano l'utente con
|
||||
`-m` e impostano `UV_CACHE_DIR=/tmp/uv-cache`, ma questo non cambia da solo
|
||||
la proprietà di un volume già esistente: se si riparte con le nuove immagini
|
||||
su un volume creato da una versione precedente, il container `server` non
|
||||
riesce a scrivere sotto `/app/uploads`.
|
||||
|
||||
WORKDIR /app/client
|
||||
|
||||
COPY client/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY client/ .
|
||||
COPY .env ..
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:5000", "app:app"]
|
||||
```
|
||||
|
||||
Build and run:
|
||||
Prima del primo avvio con le nuove immagini, va quindi corretta la proprietà
|
||||
del volume:
|
||||
|
||||
```bash
|
||||
docker build -f client/Dockerfile -t tiemeasureflow-web .
|
||||
docker run -p 5000:5000 --env-file .env tiemeasureflow-web
|
||||
docker compose -f docker-compose.yml run --rm --user root --entrypoint chown server -R 10001:10001 /app/uploads
|
||||
```
|
||||
|
||||
(sostituire `docker-compose.yml` con `docker-compose.dev.yml` per
|
||||
un'installazione di sviluppo/on-premise). Il comando usa il servizio
|
||||
`server` perché è quello che monta il volume `upload_data` in lettura e
|
||||
scrittura; il worker di visione lo monta in sola lettura e non ne ha
|
||||
bisogno. Un'installazione nuova, che parte da un volume vuoto, non richiede
|
||||
questo passo.
|
||||
|
||||
---
|
||||
|
||||
## SSL/TLS
|
||||
|
||||
### Self-Signed Certificate (Development)
|
||||
Le due installazioni gestiscono il TLS in due modi diversi, e nessuno dei due
|
||||
richiede un certificato generato a mano:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:4096 -keyout server.key -out server.crt -days 365 -nodes
|
||||
```
|
||||
- **Produzione** (`docker-compose.yml`): Traefik ottiene e rinnova da solo un
|
||||
certificato Let's Encrypt per `APP_DOMAIN`, tramite il `certresolver`
|
||||
configurato nelle label del servizio (`tls.certresolver=mytlschallenge`).
|
||||
Serve solo che `APP_DOMAIN` punti davvero al server (DNS) e che le porte
|
||||
80/443 siano raggiungibili da internet per la sfida ACME.
|
||||
- **On-premise, rete LAN** (`docker-compose.dev.yml`): Nginx serve in HTTP
|
||||
semplice, senza TLS — è il caso descritto in
|
||||
[Recommended Architecture](#recommended-architecture). Chi ha comunque
|
||||
bisogno di un certificato (es. una CA interna) lo aggiunge scommentando la
|
||||
sezione SSL già presente, ma commentata, in
|
||||
[`nginx/nginx.conf`](../nginx/nginx.conf).
|
||||
|
||||
### Let's Encrypt (Production)
|
||||
|
||||
```bash
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
sudo certbot certonly --nginx -d yourdomain.com
|
||||
```
|
||||
|
||||
### Configure in .env
|
||||
|
||||
```env
|
||||
SSL_CERTFILE=/etc/ssl/certs/yourdomain.com.crt
|
||||
SSL_KEYFILE=/etc/ssl/private/yourdomain.com.key
|
||||
```
|
||||
|
||||
### Start Server with SSL
|
||||
|
||||
```bash
|
||||
cd server
|
||||
gunicorn main:app \
|
||||
--workers 4 \
|
||||
--worker-class uvicorn.workers.UvicornWorker \
|
||||
--bind 0.0.0.0:8443 \
|
||||
--certfile /etc/ssl/certs/yourdomain.com.crt \
|
||||
--keyfile /etc/ssl/private/yourdomain.com.key
|
||||
```
|
||||
Un'installazione manuale (senza Docker, sezioni [Server](#server-deployment) e
|
||||
[Client Deployment](#client-deployment) sopra) che debba terminare il TLS da
|
||||
sola, dietro il proprio reverse proxy, può ancora valorizzare
|
||||
`SSL_CERTFILE`/`SSL_KEYFILE` in `.env` — variabili lasciate per questo caso,
|
||||
non lette da uvicorn/gunicorn nei comandi mostrati sopra: vanno passate al
|
||||
proprio reverse proxy o gestite fuori da questo progetto.
|
||||
|
||||
---
|
||||
|
||||
@@ -644,14 +548,11 @@ sqlalchemy.exc.OperationalError: (asyncmy.errors.ProgrammingError) (2003, "Can't
|
||||
|
||||
**Check:**
|
||||
```bash
|
||||
# Verify MySQL is running
|
||||
# Verify MySQL is running (con Docker: docker compose ps mysql, ha già un healthcheck)
|
||||
mysql -u tmflow -p -e "SELECT 1"
|
||||
|
||||
# Check .env variables
|
||||
# Check .env variables — non stampare mai la password: si controlla il valore, non la connection string
|
||||
grep DB_ .env
|
||||
|
||||
# Test connection string
|
||||
cd server && python -c "from config import settings; print(settings.database_url)"
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
@@ -667,7 +568,7 @@ lsof -i :8000
|
||||
kill -9 <PID>
|
||||
|
||||
# Or use different port
|
||||
uvicorn main:app --port 8001
|
||||
uv run uvicorn src.backend.main:app --port 8001
|
||||
```
|
||||
|
||||
### Import Errors
|
||||
@@ -678,21 +579,20 @@ ModuleNotFoundError: No module named 'fastapi'
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd server
|
||||
pip install -r requirements.txt
|
||||
uv sync --extra server --extra client --extra dev
|
||||
```
|
||||
|
||||
### File Upload Not Working
|
||||
|
||||
```
|
||||
/app/server/uploads: Permission denied
|
||||
uploads: Permission denied
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Create uploads directory with correct permissions
|
||||
mkdir -p server/uploads
|
||||
chmod 755 server/uploads
|
||||
# Create uploads directory with correct permissions (radice del repo, non server/uploads)
|
||||
mkdir -p uploads/images uploads/pdfs uploads/logos uploads/reports
|
||||
chmod 755 uploads
|
||||
```
|
||||
|
||||
### Migrations Failed
|
||||
@@ -703,13 +603,12 @@ alembic.util.exc.CommandError: Can't locate revision identified by...
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
cd server
|
||||
# Check migration status
|
||||
alembic current
|
||||
uv run alembic -c src/backend/migrations/alembic.ini current
|
||||
|
||||
# Reset migrations (CAUTION - deletes data)
|
||||
alembic downgrade base
|
||||
alembic upgrade head
|
||||
uv run alembic -c src/backend/migrations/alembic.ini downgrade base
|
||||
uv run alembic -c src/backend/migrations/alembic.ini upgrade head
|
||||
```
|
||||
|
||||
### API Key Not Working
|
||||
|
||||
+10
-3
@@ -6,8 +6,11 @@ Indice della documentazione del progetto.
|
||||
|
||||
| Documento | Scopo |
|
||||
|---|---|
|
||||
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V2.0.0). Snapshot del sistema, componenti e capacità. |
|
||||
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta da fare. Fasi 2-7 della migrazione rev04 verso V1.1.0/M1 demo cliente. |
|
||||
| [`../TieMeasureFlow_modifiche_2026-07-28.md`](../TieMeasureFlow_modifiche_2026-07-28.md) | **Il piano di lavoro corrente**: i quindici punti del sopralluogo del 28/07 e le decisioni in attesa del cliente (D-1…D-9). |
|
||||
| [`architecture/STATO_PROGETTO.md`](architecture/STATO_PROGETTO.md) | Cosa è fatto oggi (V3.0.0), punto per punto, e cosa non è ancora stato provato sul campo. |
|
||||
| [`architecture/ROADMAP.md`](architecture/ROADMAP.md) | Cosa resta, in ordine: collaudo, punto 4, innesto GAIA, installazione a Tràfilo. Include la mappatura con le Fasi rev04 di aprile. |
|
||||
| [`architecture/LAYOUT.md`](architecture/LAYOUT.md) | La cornice delle pagine: perché il layout si spostava fra le viste e la regola che lo tiene fermo. |
|
||||
| [`../src/frontend/flask_app/static/vendor/VERSIONS.md`](../src/frontend/flask_app/static/vendor/VERSIONS.md) | Librerie di terze parti in locale: versioni, impronte SHA-256, come aggiornarle. |
|
||||
|
||||
## Riferimenti operativi
|
||||
|
||||
@@ -17,8 +20,12 @@ Indice della documentazione del progetto.
|
||||
| [`DEPLOYMENT.md`](DEPLOYMENT.md) | Guida deploy su VPS (Hostinger/Tielogic con Traefik + Let's Encrypt). |
|
||||
| [`USER_GUIDE.md`](USER_GUIDE.md) | Manuale utente (operatore, maker, metrologist, admin). |
|
||||
| [`I18N_SETUP.md`](I18N_SETUP.md) | Setup e workflow traduzioni (Flask-Babel + Alpine.js). |
|
||||
| [`COLLAUDO.md`](COLLAUDO.md) | **Scaletta di collaudo V3.0.0**: cosa provare, in che ordine, con quali valori, e come si vede che è giusto. |
|
||||
|
||||
## Piani dettagliati TDD (rev04)
|
||||
## 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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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)
|
||||
- Spec sorgente: [`../specs/2026-04-16-schema-sviluppo-rev04.docx`](../specs/2026-04-16-schema-sviluppo-rev04.docx)
|
||||
- Stato corrente: [`STATO_PROGETTO.md`](STATO_PROGETTO.md)
|
||||
La roadmap rev04 di aprile (Fasi 1-7, milestone M1/M2) è **superata dai fatti**: il
|
||||
sopralluogo del 28/07 ha prodotto un elenco di quindici punti concreti che è oggi il
|
||||
piano di lavoro. Buona parte delle vecchie Fasi 2 e 4 è dentro quei punti ed è già
|
||||
fatta; il resto è confluito o decaduto. La mappatura è in fondo, per non perdere il
|
||||
filo con i documenti di aprile.
|
||||
|
||||
## 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 |
|
||||
| **M2 — Produzione** | Fasi 6-7 + correzioni post-feedback + GAIA live | Rollout su tablet/PC reali |
|
||||
| 1 | Memoria della produzione in corso | ✅ fatto |
|
||||
| 2 | Tipo di task esplicito | ✅ fatto |
|
||||
| 3 | Loop di misura e ripetizione | ✅ fatto |
|
||||
| 4 | Limite di tentativi prima del capoturno | ⛔ **fermo** su D-6, D-7, D-9 |
|
||||
| 5 | Avanzamento solo se in tolleranza | ✅ fatto |
|
||||
| 6 | Fermo linea e Fine produzione con effetto | ✅ fatto (innesto GAIA fermo su D-1, D-2) |
|
||||
| 7 | Gestione delle stazioni | ✅ fatto |
|
||||
| 8 | Tracciabilità obbligatoria | ✅ fatto |
|
||||
| 9 | Blocco dell'inserimento manuale | ✅ fatto |
|
||||
| 10 | Interfaccia operatore: sequenza e conferme | ✅ fatto |
|
||||
| 11 | Formattazione delle descrizioni | ✅ fatto |
|
||||
| 12 | Funzionamento senza internet | ✅ fatto |
|
||||
| 13 | Generazione task dalla scheda tecnica con l'AI | — fuori offerta (quotazione su D-8) |
|
||||
| 14 | Stabilità del layout | ✅ fatto |
|
||||
| 15 | Statistica separata dalla registrazione | ✅ fatto |
|
||||
|
||||
## 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`) |
|
||||
| 2 | Ruolo Capoturno (Supervisor) + override token breve | ⏳ Da iniziare | — |
|
||||
| 3 | Editor ricetta a blocchi (preparation + measurement) | ⏳ Da iniziare | — |
|
||||
| 4 | Workflow operatore (retry/timer/autologout/avvio produzione) | ⏳ Da iniziare | — |
|
||||
| 5 (M1) | `ImportOnlyGaiaClient` + UI import dati cliente reali | ⏳ Da iniziare | — |
|
||||
| Deploy M1 | VPS demo (compose + Traefik + LE, no registry) | ⏳ Da iniziare | — |
|
||||
| **D-1** | Protocollo del gestionale GAIA: servizi web, database condiviso, file? | innesto GAIA (punto 6) | IT Tràfilo + fornitore GAIA |
|
||||
| **D-2** | Rete e credenziali per raggiungere GAIA | come sopra | IT Tràfilo |
|
||||
| ~~D-3~~ | ~~Una app per stazione o una sola?~~ **Decisa il 28/07**: dati sul server, app di stazione su ogni PC | — | chiusa; validazione in D-4 |
|
||||
| **D-4** | Server: quale macchina, quanto spazio disco (database **e** disegni) | installazione | IT Tràfilo |
|
||||
| **D-5** | Cicalino: basta il suono del browser o serve una colonnina luminosa? | punto 3 (completamento) | Tràfilo |
|
||||
| **D-6** | Autorizzazione capoturno: password come oggi, o PIN / badge? Venti volte al giorno la password è attrito | punti 4, 5, 6 | Tràfilo |
|
||||
| **D-7** | Quanti tentativi prima del capoturno, e uguali per tutte le ricette? | punto 4 | Tràfilo |
|
||||
| **D-8** | Schede tecniche: quante, formato standard, conversione una-tantum o funzione permanente? | punto 13 e la sua quotazione | Tràfilo |
|
||||
| **D-9** | Cambiare i parametri di una ricetta (timer, tentativi) crea una nuova versione? | punti 3, 4 | noi, con conferma cliente |
|
||||
|
||||
## 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 |
|
||||
|---|---|---|
|
||||
| 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
|
||||
## Tech debt
|
||||
|
||||
| Item | Priorità | Note |
|
||||
|---|---|---|
|
||||
| 3 test backend pre-esistenti rotti (`test_recipes`, `test_tasks`) | Media | Investigare prima di Fase 3 (toccano recipe + task router). |
|
||||
| 1 test client pre-esistente rotto (`test_save_measurement_proxy`) | Bassa | Probabilmente CSRF/payload. Risolvere con Fase 4. |
|
||||
| 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 (impatto deploy). |
|
||||
| Header `X-API-Key` rename a `X-Api-Key` | Bassa | Vedere se M2 lo richiede. |
|
||||
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 in M2. |
|
||||
| `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. |
|
||||
| `components/barcode_scanner.html` dipende da `html5-qrcode`, mai caricata | Media | Codice morto: il componente non è incluso da nessuna parte e il lettore che l'operatore usa è un campo di testo. Da rimuovere, o da completare portando la libreria in `static/vendor/` |
|
||||
| Pagina `task_complete`: riga vuota segnalata in alcuni scenari | Media | Segnalazione di aprile mai riprodotta. Da verificare durante il collaudo, ora che il flusso è cambiato |
|
||||
| `.env` rename a convenzione spec (SERVICE_NAME, SERVICE_DOMAIN, API_KEY) | Bassa | Rinviato: impatta i deploy esistenti |
|
||||
| Header `X-API-Key` → `X-Api-Key` | Bassa | Breaking per i deploy esistenti |
|
||||
| Envelope risposta `{success,data,error}` | Bassa | Eventuale API v2 |
|
||||
| Test di carico a 20 tablet reali | Bassa | La capacità è dimensionata ma mai misurata sotto carico vero |
|
||||
|
||||
## 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).
|
||||
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).
|
||||
## Mappatura con la roadmap rev04 di aprile
|
||||
|
||||
## 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 |
|
||||
| Fase 3 — Block editor | 1.5 settimane |
|
||||
| Fase 4 — Workflow operatore | 2 settimane |
|
||||
| Fase 5 (M1) — Import-only GAIA | 1 settimana |
|
||||
| Deploy M1 demo | 0.5 settimane |
|
||||
| **Totale M1 residuo** | **~6 settimane** |
|
||||
| 1 — Stazioni per-tablet | Chiusa in V2.0.0, ampliata dal punto 7 |
|
||||
| 2 — Ruolo Capoturno + override | Assorbita dai punti 5 e 6: il ruolo `Supervisor` esiste e l'autorizzazione resta scritta sulla misura. L'override a token breve dipende da D-6 |
|
||||
| 3 — Editor ricetta a blocchi | Sostituita dal punto 2 (tipo di task dichiarato), che risolve il problema vero senza riscrivere l'editor |
|
||||
| 4 — Workflow operatore | Assorbita dai punti 3, 4, 10: timer e sequenza sono fatti, i tentativi sono il punto 4 |
|
||||
| 5 — Import GAIA | Diventata l'innesto del punto 6, ferma su D-1 e D-2 |
|
||||
| 6 — Deploy industriale (registry + Watchtower) | Ridimensionata: con una rete isolata l'aggiornamento automatico non ha senso. Restano immagini versionate portate a mano |
|
||||
| 7 — Hardening | Parzialmente assorbita: CSP, versioni congelate e impronte sono entrate col punto 12 |
|
||||
|
||||
## Stima tempi M2 (dopo feedback)
|
||||
|
||||
| Task | Stima |
|
||||
|---|---|
|
||||
| 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.
|
||||
Le vecchie decisioni `D-0.x` sono confluite nelle `D-x` qui sopra: D-0.1→D-1,
|
||||
D-0.2→D-2, D-0.4→D-5, D-0.5→D-9, D-0.6→D-6. D-0.8 (nome del ruolo capoturno) è
|
||||
chiusa: si chiama `Supervisor`. D-0.7 (auto-logout) era già risolta.
|
||||
|
||||
+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
|
||||
|
||||
**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
|
||||
|
||||
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
|
||||
- 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.
|
||||
Tredici punti su quindici sono fatti e in esercizio. Uno è fermo sulle risposte del
|
||||
cliente, uno è fuori offerta.
|
||||
|
||||
### 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.
|
||||
## I quindici punti
|
||||
|
||||
### 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).
|
||||
| # | Punto | Stato | Dove vive |
|
||||
|---|---|---|---|
|
||||
| 1 | Memoria della produzione in corso | ✅ | `production_runs`, `production_service` |
|
||||
| 2 | Tipo di task esplicito | ✅ | `RecipeTask.task_type`, migrazione 007 |
|
||||
| 3 | Loop di misura e ripetizione | ✅ | `production-clock.js`, `production_service`, migrazione 008 |
|
||||
| 4 | Limite di tentativi prima del capoturno | ⛔ **Fermo su D-6, D-7, D-9** | — |
|
||||
| 5 | Avanzamento solo se in tolleranza | ✅ | `measurement_service.pending_authorisation`, migrazione 010 |
|
||||
| 6 | Fermo linea e Fine produzione con effetto | ✅ | `production_service`, `production_export_service` |
|
||||
| 7 | Gestione delle stazioni | ✅ | `station_service`, `/admin/stations` |
|
||||
| 8 | Tracciabilità obbligatoria | ✅ | `Recipe.requires_lot/serial`, migrazione 009 |
|
||||
| 9 | Blocco dell'inserimento manuale | ✅ | `Recipe.allow_manual_input`, `numpad.js` |
|
||||
| 10 | Interfaccia operatore: sequenza e conferme | ✅ | `/measure/start`, `task-progress`, `task_list`, `task_execute` |
|
||||
| 11 | Formattazione delle descrizioni | ✅ | filtro `rich_text`, `rich-text.js` |
|
||||
| 12 | Funzionamento senza internet | ✅ | `static/vendor/`, CSP in `app.py` |
|
||||
| 13 | Generazione task dalla scheda tecnica con l'AI | — **Fuori offerta** | — |
|
||||
| 14 | Stabilità del layout | ✅ | `.tmf-page` in `themes.css`, [`LAYOUT.md`](LAYOUT.md) |
|
||||
| 15 | Statistica separata dalla registrazione | ✅ | `@role_required("Metrologist")`, test dedicato |
|
||||
|
||||
### 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.
|
||||
## Cosa è entrato in V3.0.0
|
||||
|
||||
### 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"`.
|
||||
- **`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.
|
||||
### Il ciclo di misura (punto 3)
|
||||
|
||||
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.
|
||||
|
||||
```
|
||||
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/
|
||||
```
|
||||
### Il fuori tolleranza (punto 5)
|
||||
|
||||
## 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
|
||||
- ✅ 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
|
||||
- La ricetta si apre sul primo task (`/measure/start/<id>`), non su un elenco. La
|
||||
lista scende a secondo livello e mostra quali task sono rimasti incompiuti
|
||||
(`GET /api/measurements/task-progress`, contato per quota e non per tentativo).
|
||||
- «Fine ciclo misura» è visibile da subito e spento finché mancano quote, con
|
||||
scritto quante ne mancano.
|
||||
- Descrizioni con `**grassetto**` e a capo: marcatura, non HTML, così la
|
||||
sanificazione è per costruzione.
|
||||
- Una cornice sola per tutte le viste, allineata alla navbar — vedi [`LAYOUT.md`](LAYOUT.md).
|
||||
- Nessun percorso dell'operatore porta alla statistica, ed è un test.
|
||||
|
||||
### Fuori dalla rete (punto 12)
|
||||
|
||||
- Alpine, Plotly, PDF.js (+ worker), Fabric e i font sono nell'installazione, con
|
||||
versione nel nome e impronta SHA-256 verificata da un test.
|
||||
- Content-Security-Policy a sola origine locale servita **dal client Flask**: prima
|
||||
esisteva solo sul backend, cioè sulle risposte API e non sulle pagine.
|
||||
- Tailwind fissato a `3.4.19` nel `Dockerfile.frontend`.
|
||||
|
||||
## Migrazioni
|
||||
|
||||
| # | Contenuto |
|
||||
|---|---|
|
||||
| 001–004 | image_path, stazioni, intervallo di misura + auto-logout, `input_duration_ms` |
|
||||
| 005–006 | `production_runs`, misure legate alla produzione |
|
||||
| 007 | `task_type` dichiarato |
|
||||
| 008 | tipi di evento del ciclo + `task_id` sull'evento |
|
||||
| 009 | `requires_lot`, `requires_serial`, `allow_manual_input` |
|
||||
| 010 | `supervisor_id`, `authorised_at` sulla misura |
|
||||
|
||||
Le 008 e 010 usano il *batch mode* per compatibilità SQLite (i test) e sono state
|
||||
verificate anche in resa MySQL (`alembic upgrade X:Y --sql`) prima del deploy.
|
||||
|
||||
La 009 imposta `allow_manual_input = 1` sulle ricette esistenti: conserva il
|
||||
comportamento in essere invece di irrigidire di colpo ricette già in uso. È una
|
||||
scelta, e va confermata dal cliente ricetta per ricetta.
|
||||
|
||||
## Test status
|
||||
| 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.
|
||||
- **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).
|
||||
I quattro fallimenti pre-esistenti tracciati nello snapshot V2.0.0 non ci sono più.
|
||||
|
||||
Tre file di test non renderizzano niente e leggono i sorgenti, perché guardano
|
||||
proprietà che sopravvivono solo se qualcuno le controlla:
|
||||
|
||||
| File | Cosa impedisce |
|
||||
|---|---|
|
||||
| `test_offline.py` | Una libreria caricata dalla rete, un worker PDF.js lasciato sul CDN, una libreria sostituita senza aggiornare l'impronta |
|
||||
| `test_layout_shell.py` | Una vista che torna a dichiararsi la propria larghezza |
|
||||
| `test_template_js_syntax.py` | Una traduzione con l'apostrofo dentro una stringa JS a virgolette singole, che spegne Alpine su tutta la pagina |
|
||||
|
||||
## Cosa non è stato provato
|
||||
|
||||
Va detto perché non si confonda «i test passano» con «funziona in reparto».
|
||||
|
||||
- **Il collaudo sul campo non è mai stato percorso.** Le ricette `COLLAUDO-A` e
|
||||
`COLLAUDO-B` sono state seminate sul sistema in esercizio (`scripts/seed_collaudo.py`)
|
||||
insieme a un utente `capoturno`, ma nessuno ha ancora guidato il flusso su un
|
||||
tablet vero.
|
||||
- **Il punto 14 non è stato riprodotto su un dispositivo**: è dimostrato che il
|
||||
layout *poteva* muoversi per quattro motivi e che ora non può più, non che
|
||||
l'operatore vedesse esattamente quelli.
|
||||
- **Il punto 12 non è stato provato a rete staccata.** È dimostrato che nessuna
|
||||
risorsa esterna viene richiesta. Il giro con la rete staccata va fatto, ed è ora il
|
||||
momento giusto perché la CSP è appena entrata in vigore.
|
||||
- **Il cicalino** è il suono del browser. Se serva una colonnina luminosa è D-5.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 +
|
||||
WeasyPrint + Plotly/Kaleido. 12 router.
|
||||
- **Frontend:** Flask + Jinja2 + Alpine.js 3.15.12 + TailwindCSS 3.4.19 +
|
||||
Fabric.js 5.3.1 + PDF.js 3.11.174 + Plotly.js 2.32.0 + Flask-Babel —
|
||||
**tutte copie locali**, nessun CDN.
|
||||
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt.
|
||||
- **Tooling:** uv, pytest + pytest-asyncio + httpx + aiosqlite.
|
||||
|
||||
## Decisioni architetturali rilevanti
|
||||
|
||||
| Decisione | Stato | Note |
|
||||
|---|---|---|
|
||||
| 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). |
|
||||
| Le regole di misura stanno sul server, non sullo schermo | **Confermata (V3.0.0)** | Tracciabilità, inserimento manuale, fuori tolleranza: la schermata può nasconderle, il server le rifiuta. |
|
||||
| Una app di stazione per PC, dati sul server | **Decisa il 28/07** (D-3) | Validazione con l'IT di Tràfilo aperta come D-4. |
|
||||
| Descrizioni in marcatura, non in HTML | **Confermata** | Niente HTML accettato in ingresso: la sanificazione è per costruzione, non per filtro. |
|
||||
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, calibri USB, editor Fabric.js, i18n Babel collaudato. |
|
||||
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, nessun microservizio. |
|
||||
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo alto, rotture client. Eventuale API v2. |
|
||||
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare è breaking per i deploy esistenti. |
|
||||
|
||||
## Branch git
|
||||
|
||||
- **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`)
|
||||
- **Corrente:** `V3.0.0`
|
||||
- **Precedenti:** `V2.0.0`, `V1.0.0` … `V1.0.7` (release storiche)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Stato Progetto TieMeasureFlow — V2.0.0
|
||||
|
||||
> Snapshot al 2026-04-27. Aggiornare ad ogni milestone.
|
||||
|
||||
## Versione corrente
|
||||
|
||||
**V2.0.0** (in sviluppo, branch `V2.0.0` come default su `git.tielogic.xyz`).
|
||||
|
||||
Versione precedente di produzione: `V1.0.7`.
|
||||
|
||||
## Sintesi esecutiva
|
||||
|
||||
Il sistema base (V1.0.7) è completo e collaudato: ricette, task, misurazioni, SPC, report PDF, gestione utenti, dashboard metrologist. La V2.0.0 in corso aggiunge il primo blocco della migrazione **rev04** (stazioni per-tablet) e ristruttura l'intero monorepo secondo lo standard `python-project-spec-design.md` (uv + `src/backend/` + `src/frontend/flask_app/`).
|
||||
|
||||
## Cosa funziona oggi (V2.0.0 — branch corrente)
|
||||
|
||||
### Funzionalità ereditate da V1.0.7
|
||||
- Autenticazione username/password + API key per-utente, ruoli combinabili (Maker, MeasurementTec, Metrologist) + flag `is_admin`.
|
||||
- Recipe versioning copy-on-write: una nuova versione si crea solo se la corrente ha già measurements; altrimenti update in-place.
|
||||
- Editor ricette (Maker) con annotation editor Fabric.js (~1200 LOC, collaudato su tablet).
|
||||
- Workflow operatore tablet: select_recipe → task_list → task_execute → task_complete, con barcode scanner e numpad touch (input USB calibro con burst detection).
|
||||
- Calcolo pass/fail con limiti UTL/UWL/LWL/LTL.
|
||||
- Dashboard SPC: capability (Cp/Cpk/Pp/Ppk), control chart (UCL/LCL = mean ± 3σ), istogramma con curva normale, calcoli puro stdlib (no numpy).
|
||||
- Report PDF (WeasyPrint + Kaleido SVG).
|
||||
- Setup page protetta da `SETUP_PASSWORD` per inizializzazione DB e seed.
|
||||
- i18n IT/EN (Flask-Babel + Alpine.js JSON).
|
||||
- Tema light/dark via `Alpine.store('theme')` + localStorage.
|
||||
|
||||
### Aggiunte V2.0.0 (rev04 Fase 1 — Stazioni per-tablet)
|
||||
- Tabelle `stations` + `station_recipe_assignments` (Alembic migration `002_add_stations.py`).
|
||||
- Modelli ORM: `Station`, `StationRecipeAssignment` con vincolo unique `(station_id, recipe_id)`.
|
||||
- Schemas Pydantic: `StationCreate/Update/Response`, `StationRecipeAssignmentCreate/Response`, `RecipeSummary`.
|
||||
- Service `station_service` (CRUD + assegnazioni + cascade delete).
|
||||
- Router `/api/stations` con CRUD admin + endpoint operatore `GET /api/stations/by-code/{code}/recipes`.
|
||||
- Seed automatico `ST-DEFAULT` con tutte le ricette esistenti (idempotente).
|
||||
- Variabile env client `STATION_CODE` letta da `Config`, helper `APIClient.get_station_recipes()`.
|
||||
- Filtro `select_recipe`: il client mostra solo le ricette assegnate alla propria stazione, errore se `STATION_CODE` non configurato.
|
||||
- **GUI admin completa** in `/admin/stations`: tabella con search, modal create/edit, modal gestione assegnazioni ricette, conferma eliminazione, link in navbar (desktop + mobile).
|
||||
- 47 nuovi test (32 server + 15 client) tutti pass.
|
||||
|
||||
### Aggiunte V2.0.0 (performance + multi-utente)
|
||||
- Gunicorn 5 workers × 4 thread (gthread) — capacità ~20 richieste concorrenti Flask, regge 20+ tablet.
|
||||
- Uvicorn 4 workers + `--proxy-headers --forwarded-allow-ips='*'`.
|
||||
- Rate limit middleware: identificazione IP reale via `X-Forwarded-For` → `X-Real-IP` → `request.client.host`.
|
||||
- Rate limit general 100 → 300 req/min/IP (per-tablet ora, non più condiviso).
|
||||
- Flask `ProxyFix(x_for=1, x_proto=1, x_host=1)` per IP reale dietro Nginx.
|
||||
- `APIClient` propaga `X-Forwarded-For` + `X-Real-IP` (sia JSON che multipart).
|
||||
- 12 test aggiuntivi (7 server + 5 client).
|
||||
|
||||
### Aggiunte V2.0.0 (struttura monorepo)
|
||||
- `pyproject.toml` unico con extra `server`/`client`/`dev`. Niente più `requirements.txt`.
|
||||
- `uv.lock` (77 pacchetti) + `.python-version` (3.11) committati per build riproducibili.
|
||||
- Layout `src/backend/` + `src/frontend/flask_app/` (vedi sotto).
|
||||
- `Dockerfile` (root) + `Dockerfile.frontend` riscritti con `uv sync --frozen --no-dev --extra server|client`.
|
||||
- `docker-compose.{dev,}.yml` con build context `.`.
|
||||
- Alembic env.py aggiunge project root a `sys.path`; `script_location = %(here)s` resta valido.
|
||||
- `.dockerignore` aggiornato.
|
||||
|
||||
### Hardening post-restructure (smoke test 2026-04-26)
|
||||
|
||||
Sequenza di smoke test in locale (uvicorn + gunicorn + MySQL Docker) ha fatto emergere quattro regressioni che sarebbero rimaste invisibili al test suite:
|
||||
|
||||
- **`src/backend/config.py`**: `env_file` era cwd-relative (`../../.env`). Rotto fuori da `src/backend/`. Risolto con percorso assoluto `Path(__file__).resolve().parents[2] / ".env"`.
|
||||
- **`src/backend/models/orm/__init__.py`**: `Station` e `StationRecipeAssignment` non erano esportati, quindi `Base.metadata.create_all` non creava le tabelle stations. Aggiunti agli import.
|
||||
- **`.env.example`**: `UPLOAD_DIR=server/uploads` era residuo della vecchia struttura → file landavano fuori dall'albero di progetto. Aggiornato a `UPLOAD_DIR=uploads`.
|
||||
- **Apostrofi italiani in template Alpine** (`l'utente`, `nell'eliminazione`, `nell'assegnazione`): chiudevano prematuramente JS string literals dentro `x-text` e blocchi `<script>`. Riscritti con delimitatori `"..."` o riformulazione testuale.
|
||||
|
||||
Inoltre **UX rework** della modale assegnazione ricette su `/admin/stations`: dropdown sostituita da layout a 2 colonne (disponibili / assegnate) con bottone inline `+ Assegna`, search filter, empty state esplicativo (mostrava silenziosamente lista vuota se tutte le ricette erano già assegnate).
|
||||
|
||||
Test guard aggiunto: `test_template_js_syntax.py` valida ogni inline `<script>` E ogni espressione Alpine (`x-*`, `@*`, `:*`) della pagina con `node --check`. Cattura automaticamente il bug-class apostrofo. Skip se Node non è installato.
|
||||
|
||||
## Layout repository (V2.0.0)
|
||||
|
||||
```
|
||||
TieMeasureFlow/
|
||||
├── pyproject.toml + uv.lock + .python-version
|
||||
├── Dockerfile (backend) + Dockerfile.frontend
|
||||
├── docker-compose.dev.yml + docker-compose.yml
|
||||
├── nginx/
|
||||
├── uploads/ # volume Docker
|
||||
├── docs/ # raggruppata e indicizzata
|
||||
│ ├── README.md (indice)
|
||||
│ ├── API.md / DEPLOYMENT.md / USER_GUIDE.md / I18N_SETUP.md
|
||||
│ ├── architecture/ # questo file + ROADMAP.md
|
||||
│ ├── archive/ # piani storici
|
||||
│ ├── specs/ # spec esterne (.docx)
|
||||
│ └── superpowers/plans/ # piani TDD dettagliati
|
||||
└── src/
|
||||
├── backend/
|
||||
│ ├── main.py / config.py / database.py
|
||||
│ ├── api/{routers,middleware}/
|
||||
│ ├── models/{orm,api}/
|
||||
│ ├── services/
|
||||
│ ├── migrations/
|
||||
│ ├── templates/
|
||||
│ └── tests/
|
||||
└── frontend/
|
||||
└── flask_app/
|
||||
├── app.py / config.py / compile_translations.py
|
||||
├── blueprints/ (auth, maker, measure, statistics, admin)
|
||||
├── services/ (api_client.py)
|
||||
├── templates/ + static/ + translations/
|
||||
└── tests/
|
||||
```
|
||||
|
||||
## Smoke test status
|
||||
|
||||
Validazione end-to-end in locale (2026-04-26):
|
||||
|
||||
- ✅ MySQL container Docker up, schema creato, alembic stamp head OK
|
||||
- ✅ uvicorn `--reload` su :8000, `/api/health` risponde
|
||||
- ✅ Seed `/api/setup/seed` con `SETUP_PASSWORD=adriano77` → admin + 4 utenti demo + DEMO-001 + ST-DEFAULT con assegnazione automatica
|
||||
- ✅ Login `admin/admin123` via web, sessione persistente
|
||||
- ✅ `/admin/stations`: tabella, modal create/edit, modal gestione assegnazioni a 2 colonne con search, eliminazione con cascade
|
||||
- ✅ `/admin/users`, `/maker/recipes`, `/measure/select` (filtrato per stazione), `/statistics/dashboard`
|
||||
- ✅ Workflow MeasurementTec end-to-end: select_recipe → task_list → task_execute → task_complete (riepilogo con misure)
|
||||
- ✅ Hot reload Flask + uvicorn `--reload` + Tailwind watch attivi durante lo sviluppo
|
||||
|
||||
## Test status
|
||||
| Backend (`src/backend/tests/`) | 127 | 3 | Fail pre-esistenti: `test_recipes` (2) + `test_tasks` (1). Nessuno introdotto dalla V2.0.0. |
|
||||
| Frontend (`src/frontend/flask_app/tests/`) | 46 | 1 | +2 test post-restructure (`test_template_js_syntax.py`). Fail pre-esistente: `test_save_measurement_proxy`. |
|
||||
| **Totale** | **173** | **4** | Tutti i fallimenti tracciati come tech debt da risolvere. |
|
||||
|
||||
## Stack confermato
|
||||
|
||||
- **Backend:** FastAPI + SQLAlchemy 2.0 async + MySQL 8 + Alembic + Pydantic v2 + WeasyPrint + Plotly/Kaleido.
|
||||
- **Frontend:** Flask + Jinja2 + Alpine.js + TailwindCSS + Fabric.js 5.3.1 + html5-qrcode + Plotly.js + Flask-Babel.
|
||||
- **Deploy:** Docker Compose. Dev = Nginx; Prod = Traefik + Let's Encrypt SSL.
|
||||
- **Tooling:** uv (package mgmt), pytest + pytest-asyncio + httpx + aiosqlite (test).
|
||||
|
||||
## Decisioni architetturali rilevanti
|
||||
|
||||
| Decisione | Stato | Note |
|
||||
|---|---|---|
|
||||
| Frontend Flask invece di React (deroga vs spec §8) | **Confermata** | Tablet UX server-side, USB calipers/barcode, Fabric.js editor, i18n Babel collaudato. Vedi conversazione 2026-04-25. |
|
||||
| NATS messaging (spec §7) | **Skippato** | Monorepo single-host, no microservizi. Nessuno stub `nats_client/` creato. |
|
||||
| Envelope risposta `{success,data,error}` (spec §6) | **Rimandato** | Costo 4-5gg refactor + rotture client. Eventuale v2 API in M2. |
|
||||
| Header `X-API-Key` vs spec `X-Api-Key` | **Mantenuto attuale** | Rinominare costa 50+ punti di codice + breaking per deploy. Rivedere in M2. |
|
||||
| Variabili `.env` (DB_HOST, SERVER_PORT, ...) | **Mantenute attuali** | Rename a SERVICE_NAME/SERVICE_DOMAIN/API_KEY rinviato (impatta deploy esistenti). |
|
||||
|
||||
## Branch git
|
||||
|
||||
- **Default:** `V2.0.0` (lavoro corrente)
|
||||
- **Mantenuti:** `V1.0.0` … `V1.0.7` (release branches storiche)
|
||||
- **Mergiato e chiuso:** `feature/rev04-phase1-stations` (in `V2.0.0` con commit `ea8e468`)
|
||||
@@ -0,0 +1,367 @@
|
||||
# La visione in TieMeasureFlow — stato verificato e specifica del lavoro che manca
|
||||
|
||||
> Progetto: TieMeasureFlow, ramo **`V3.0.0`** (HEAD `39a6f62`, 17/08/2026)
|
||||
> Data: 14 settembre 2026
|
||||
> Stato: specifica da approvare
|
||||
> Sostituisce, come documento operativo: il piano `docs/superpowers/plans/2026-08-16-vision-fondamenta-server.md`
|
||||
> (eseguito) e la corsia `A1-tiemeasureflow.md` del piano ferie (eseguita, cancellata dal disco il 01/09 e
|
||||
> recuperabile con `git show 874a7a4^:docs/plans/corsie-fase2/A1-tiemeasureflow.md` nel repo AI-OS).
|
||||
> **Resta in vigore** `docs/superpowers/specs/2026-08-16-visionsuite-integration-design.md`: le sue otto
|
||||
> decisioni architetturali non vengono riscritte qui, vengono richiamate.
|
||||
|
||||
## 1. Perché questo documento
|
||||
|
||||
Fra il 16 e il 17 agosto sono entrate in TieMeasureFlow le fondamenta della visione: ventiquattro commit,
|
||||
sette file di test nuovi, tre migrazioni. Da allora il lavoro si è fermato. Nel frattempo VisionSuite è
|
||||
andato avanti di sessantasette commit, la scheda di commessa non registra nulla dopo il 25 agosto, e
|
||||
l'installazione a Tràfilo, prevista per settembre, è alle porte insieme alla fiera del 27-29 ottobre.
|
||||
|
||||
Questo documento fa due cose. Prima dichiara, verificandolo sul codice e non sui piani, **che cosa esiste
|
||||
davvero** sul ramo `V3.0.0`. Poi specifica **che cosa manca** per arrivare a ciò che il cliente ha chiesto
|
||||
il 27 luglio: pattern matching, confronto del profilo con il disegno DXF e due o tre quote lineari, dentro
|
||||
l'interfaccia di TieMeasureFlow, con l'esito OK/warning/fail che il prodotto già sa dare.
|
||||
|
||||
La distanza fra le due cose è più grande di quanto i documenti di agosto lascino credere, e la ragione è
|
||||
una sola, spiegata al capitolo 3.
|
||||
|
||||
## 2. Quello che esiste, verificato sul ramo V3.0.0
|
||||
|
||||
Ogni riga di questa tabella è stata controllata aprendo il file indicato nel clone del ramo `V3.0.0` il
|
||||
14/09/2026. Non è lo stato dichiarato da un piano: è lo stato del disco.
|
||||
|
||||
| Cosa | Stato | Dove si vede |
|
||||
|---|---|---|
|
||||
| VisionSuite come sottomodulo git, su commit fisso | ✅ | `.gitmodules`, pin `f095fcc` (15/08) |
|
||||
| Runner unico, senza HTTP né database | ✅ | `src/vision/runner.py`, 129 righe |
|
||||
| Versione del motore timbrata su ogni numero prodotto | ✅ | `runner.engine_version()`, errore se non risolve |
|
||||
| Vision worker come servizio separato nel compose | ✅ | `src/vision_worker/main.py`, `Dockerfile.vision`, servizio `vision` |
|
||||
| Il server non importa mai VisionSuite | ✅ e **sorvegliato** | `src/backend/tests/test_vision_boundary.py` |
|
||||
| Grafo di visione sul task (`vision_json`) | ✅ | migrazione `011`, `models/orm/task.py` |
|
||||
| Legame quota del grafo → quota della ricetta (`vision_output`) | ✅ | migrazione `011`, `recipe_subtasks` |
|
||||
| `camera` fra i metodi di ingresso misura | ✅ | migrazione `011`, enum `input_method` |
|
||||
| Tabella `vision_results`, una riga per acquisizione | ✅ | migrazione `012`, `models/orm/vision.py` |
|
||||
| Immagini di riferimento con provenienza | ✅ | migrazione `013`, `vision_reference_images` |
|
||||
| Il verdetto lo dà TieMeasureFlow, non il grafo | ✅ | `vision_service` chiama `measurement_service` |
|
||||
| Gate del fuori tolleranza valutato per acquisizione | ✅ | `enforce_tolerance_gate=False` più gate a monte |
|
||||
| Copy-on-write delle ricette che copia anche il grafo | ✅ | `test_vision_copy_on_write.py` |
|
||||
| Il refuso `xf_compare` corretto in `dxf_compare` | ✅ | migrazione `011`, prima che una riga lo usasse |
|
||||
| Limiti di tipo e dimensione sugli endpoint di upload | ✅ | `test_vision_upload_limits.py` |
|
||||
| Il guasto del worker resta del worker | ✅ | `vision_service._call_worker`, 502/504 nominati |
|
||||
| L'applicazione parte senza internet | ✅ | `static/vendor/`, `test_offline.py` |
|
||||
| API `POST /api/vision/execute`, `/preview`, `/reference-images` | ✅ | `api/routers/vision.py` |
|
||||
|
||||
Trentanove test di visione fra `src/backend/tests/test_vision_*.py`, `src/vision/tests/` e
|
||||
`src/vision_worker/tests/`. ⚠️ **Non li ho eseguiti**: l'albero della visione richiede Python 3.13 e il
|
||||
sottomodulo inizializzato, cioè un'installazione da alcuni gigabyte che su questa macchina non c'è. Il
|
||||
conteggio è dei file; la loro verdezza è dichiarata dai commit del 16-17 agosto e va riverificata da chi
|
||||
riprende, prima di scriverci sopra.
|
||||
|
||||
**In sintesi: le fondamenta ci sono e sono fatte bene.** Il confine architetturale è difeso da un test
|
||||
invece che da una buona intenzione, la provenienza di ogni numero è tracciata, e il modello dati regge il
|
||||
resto del lavoro senza ristrutturazioni.
|
||||
|
||||
## 3. Il buco strutturale: pattern matching e DXF compare non sono eseguibili dal grafo
|
||||
|
||||
> ✅ **Colmato in VisionSuite il 14/09/2026** (commit `6f5990c`, issue #2). Quello che segue è la
|
||||
> diagnosi com'era, e resta perché spiega perché il ponte sta dove sta. Il seguito, cioè cosa manca
|
||||
> ancora **qui**, è nella fase V1.
|
||||
|
||||
È il punto che i documenti di agosto non dicono, ed è quello che decide il calendario.
|
||||
|
||||
Il runner esegue **un grafo `vs-task`**: `Task.from_dict(graph)`, poi `task.run(context)`. Gli strumenti
|
||||
che un grafo può contenere sono quelli registrati in `vs-task`, e al 14/09 — sia sul commit fissato
|
||||
`f095fcc`, sia sull'ultimo commit di VisionSuite — sono **dieci, tutti geometrici**:
|
||||
|
||||
`DetectLine`, `DetectCircle`, `CircleCenter`, `PointIntersectionLL`, `Distance2P`, `DistancePL`,
|
||||
`DistanceCL`, `Angle2L`, `Radius`, `Diameter`.
|
||||
|
||||
Nessuno strumento di pattern matching. Nessuno strumento di confronto DXF. Una ricerca di `pm2d`,
|
||||
`shape_model` o `dxf` dentro `packages/vs-task/src` non trova nulla.
|
||||
|
||||
Coerentemente, in `pyproject.toml` gli extra `vision` e `vision-worker` installano `visionsuite` (core),
|
||||
`vs-task`, `vs-measure`, `vs-pm2d` e `numpy`: **`vs-dxf` non è installato affatto**, e `vs-pm2d` è
|
||||
installato ma il grafo non ha modo di chiamarlo.
|
||||
|
||||
I due motori esistono, e sono buoni: `visionsuite.pm2d` ha il matcher e l'auto-tune, `visionsuite.dxf` ha
|
||||
`DXFCompare`, l'`ICPAligner`, il ricampionamento, il controllo di tolleranza e il generatore di rapporto.
|
||||
Ma sono **librerie**, non strumenti di grafo. Fra loro e TieMeasureFlow manca un ponte, e il ponte va
|
||||
costruito **in VisionSuite**, non qui: la decisione 4 della spec di agosto — il server non importa mai
|
||||
VisionSuite — e il criterio di accettazione della corsia A1 — *«`grep` non trova algoritmi di visione dentro
|
||||
il repo di TieMeasureFlow»* — rendono illegittima qualunque scorciatoia che scriva visione dentro questo
|
||||
repository.
|
||||
|
||||
🔑 **La conseguenza commerciale, detta chiara:** oggi TieMeasureFlow sa eseguire un grafo che misura
|
||||
distanze, angoli, raggi e diametri su un'immagine. Le due cose che Menoncin ha chiesto per nome — il
|
||||
confronto DXF, che il suo cliente finale non ha, e il pattern matching, che il loro «funziona male» — non
|
||||
sono ancora raggiungibili da nessun percorso del prodotto.
|
||||
|
||||
## 4. Le altre lacune, in ordine di peso
|
||||
|
||||
1. **Nessuna interfaccia.** Nel frontend Flask la visione compare in due sole righe di
|
||||
`templates/measure/task_list.html`, le etichette «Confronto profilo» e «Misura camera». Non esiste la
|
||||
schermata del Maker per comporre un grafo, scegliere un'immagine di riferimento, agganciare un'uscita a
|
||||
una quota o premere «prova»; non esiste la schermata dell'operatore che acquisisce e vede l'esito. Oggi
|
||||
un grafo entra solo via `POST /api/tasks` scrivendo il JSON a mano.
|
||||
2. **Nessun agente di stazione, nessun registro dei device.** La tabella `station_devices` della spec non
|
||||
esiste in nessuna migrazione; il WebSocket `/api/stations/agent` non esiste; `vs-camera`, cioè il
|
||||
pacchetto che contiene il driver Itala, non è montato. L'unico modo di far entrare un'immagine è un
|
||||
upload HTTP.
|
||||
3. **L'immagine di produzione non viene conservata.** `vision_results.image_path` e `overlay_path`
|
||||
migrano e restano vuote: nessuna riga le scrive. Una misura contestata non ha prova materiale, e
|
||||
l'overlay — l'immagine con sopra le zone dove il sistema ha guardato — non esiste.
|
||||
4. **La prova di non regressione è promessa e non mantenuta.** `expected_json` esiste sulla tabella delle
|
||||
immagini di riferimento; nessun percorso la scrive, `preview` compreso. È la rete di sicurezza che
|
||||
rende sostenibile aggiornare il sottomodulo con il motore installato su due host: senza, ogni
|
||||
aggiornamento di VisionSuite è un salto al buio.
|
||||
5. **Il sottomodulo è indietro di 67 commit**, dal 15/08 al 10/09. Fra questi c'è almeno una correzione di
|
||||
`vs-core` sulla ricomposizione dei mosaici. Aggiornare il pin non è manutenzione facoltativa: è la
|
||||
decisione 2 della spec, che vuole server e stazione sullo stesso commit, e va fatta con la prova di non
|
||||
regressione già in piedi — cioè dopo il punto 4, non prima.
|
||||
6. **Le tre verifiche a monte non sono mai state fatte**, e la spec di agosto le poneva come condizione
|
||||
d'ingresso: lo stato reale di VisionSuite rispetto al suo README, il test di calibrazione dato per
|
||||
rosso (focale recuperata a 373 contro 456 attesi, tolleranza 5%) e il peso effettivo del sottoinsieme
|
||||
di pacchetti che il worker installa. La seconda pesa più delle altre: **tutte le quote in millimetri
|
||||
poggiano sulla calibrazione**, e finché quel test non è verde o spiegato, i millimetri che il sistema
|
||||
produce non hanno garanzia.
|
||||
|
||||
## 5. Che cosa si vuole ottenere
|
||||
|
||||
Un operatore apre un task di visione su una postazione, appoggia il profilato sul piano, e il sistema:
|
||||
|
||||
- riconosce il pezzo e la sua posa (**pattern matching**);
|
||||
- ne confronta il profilo con il **DXF di riferimento**, dichiarando lo scarto massimo e dove sta;
|
||||
- ne ricava **due o tre quote lineari** (le quote massima e minima che il cliente chiede);
|
||||
- applica le tolleranze della ricetta e dà **OK / warning / fail**, con il gate del fuori tolleranza e
|
||||
l'autorizzazione del capoturno come per qualunque altra misura;
|
||||
- registra tutto in `measurements` e `vision_results`, con immagine, overlay, versione del motore e
|
||||
taratura in vigore.
|
||||
|
||||
Nulla di quanto il prodotto già fa — ricette, SPC, Cp/Cpk, carta di controllo, export, tracciabilità —
|
||||
deve cambiare. Una quota da camera resta una misura come le altre: è la decisione 7, e va tenuta.
|
||||
|
||||
## 6. Il lavoro, in sei fasi
|
||||
|
||||
Le fasi sono ordinate per dipendenza. Le prime due sono il cammino critico verso la fiera; la terza è il
|
||||
cammino critico verso l'installazione a Tràfilo. Non sono stimate in ore: una stima si scrive dopo aver
|
||||
guardato il codice dei due motori, e quella lettura fa parte della fase V0.
|
||||
|
||||
### V0 — Le verifiche a monte, prima di scrivere righe
|
||||
|
||||
Le tre della spec di agosto (capitolo 4, punto 6), più una quarta che nasce da questo documento: leggere
|
||||
`visionsuite.pm2d.matcher` e `visionsuite.dxf.compare` per sapere **che forma hanno gli ingressi e le
|
||||
uscite** dei due motori, perché è quella forma che decide la firma degli strumenti della fase V1.
|
||||
|
||||
*Fatto quando:* il test di calibrazione è verde o il suo scarto è spiegato per iscritto; il peso
|
||||
dell'ambiente del worker è un numero misurato; esiste una nota di due pagine con le firme reali dei due
|
||||
motori.
|
||||
|
||||
### V1 — Il ponte — ✅ **fatta il 14/09/2026** (VisionSuite `6f5990c`, issue #2 chiusa)
|
||||
|
||||
**Il lavoro è stato fatto in VisionSuite**, come questo documento prescriveva. Gli strumenti non sono
|
||||
finiti in `vs-task` insieme ai dieci geometrici, ma **ciascuno nel pacchetto del proprio motore** —
|
||||
`visionsuite.pm2d.task_tools` e `visionsuite.dxf.task_tools` — perché altrimenti il livello-task avrebbe
|
||||
ereditato numba, opencv e fastapi da `pm2d` e scipy ed ezdxf da `dxf`, e se li sarebbe portati dietro
|
||||
ogni consumatore che voleva soltanto una distanza fra due punti. Si dichiarano con l'extra `task`.
|
||||
|
||||
- `MatchPattern` — ingressi: la scena dal contesto, il modello per chiave o per percorso, punteggio
|
||||
minimo, intervallo di angolo e di scala. Uscite: `found`, `score`, `row`, `col`, `angle_deg`, `scale`,
|
||||
`n_matches`.
|
||||
- `CompareDXF` — ingressi: immagine, `dxf_path`, `px_per_mm` (o la calibrazione dal contesto),
|
||||
`tolerance_mm`. Uscite: `max_deviation_mm`, `mean_deviation_mm`, `n_points`, `overlay`.
|
||||
|
||||
**Il vincolo che ha retto:** gli strumenti espongono numeri, non verdetti. `CompareResult.passed` esiste
|
||||
e resta fuori dalle uscite, e un test lo sorveglia. La `tolerance_mm` serve **solo a colorare l'overlay**:
|
||||
è un parametro di disegno, non una soglia di accettazione.
|
||||
|
||||
⚠️ **Due cose che questo documento dava per buone non hanno retto alla prova, ed è il motivo per cui una
|
||||
specifica si verifica invece di eseguirla.**
|
||||
|
||||
1. **«`vs-dxf` si porta dietro PyTorch per una riga» era falso.** Torch è stato tolto in L2 insieme al
|
||||
rilevatore DexiNed che non aveva mai girato, e il `pyproject.toml` di `vs-dxf` lo dice per esteso. La
|
||||
nota veniva dalla scheda VisionSuite in AI-OS, che su quel punto era invecchiata. Il lavoro non
|
||||
esisteva: il container della visione non triplica.
|
||||
2. **La posa iniziale non serve, ed è misurato.** Questo documento dava per scontato che `CompareDXF`
|
||||
dovesse partire allineato dalla posa di `MatchPattern`. Ruotando la scena e lasciando l'ICP senza
|
||||
aiuto, sul rettangolo forato a 4 px/mm: **0°** → 0,285 mm, **5°** → 0,346, **20°** → 0,288, **45°** →
|
||||
0,243. Converge da solo, e a 45° meglio che a zero. In più `DXFCompare._pose_to_transform` non ha
|
||||
chiamanti né test e trasla le **righe** dove il nome dice colonne: l'ingresso non è stato esposto,
|
||||
perché chi lo avesse collegato al match avrebbe ottenuto una traslazione di centinaia di pixel
|
||||
credendo di aver fatto la cosa giusta. Un test parametrico su 0°, 20° e 45° tiene ferma la scelta.
|
||||
|
||||
⚠️⚠️ **E una trappola nuova, da sapere prima di comporre ricette: su un pezzo simmetrico l'angolo del
|
||||
match è ambiguo.** Sulla scena non ruotata il rettangolo torna `angle_deg = -180` con punteggio 0,924,
|
||||
perché a 180° è identico a sé stesso. `row` e `col` restano affidabili; l'angolo, su un pezzo senza un
|
||||
riferimento che rompa la simmetria, non è una risposta. I profilati di Tràfilo sono esattamente il genere
|
||||
di pezzo in cui questo si presenta.
|
||||
|
||||
*Verificato:* `tests/test_visione_nel_grafo.py` carica una ricetta JSON che contiene **entrambi** gli
|
||||
strumenti con `Task.from_dict`, la esegue e ottiene le uscite attese. 21 test nuovi; **1242 verdi** su
|
||||
tutto il monorepo.
|
||||
|
||||
**Quello che resta da fare qui, in TieMeasureFlow** (è ciò che la issue #8 della lista lavori chiedeva, e
|
||||
non è cambiato): `vs-dxf` entra negli extra `vision` e `vision-worker` di `pyproject.toml`, il pin del
|
||||
sottomodulo avanza a `6f5990c`, e il runner **importa i due moduli** `visionsuite.pm2d.task_tools` e
|
||||
`visionsuite.dxf.task_tools` — senza quell'import il registro non li conosce e una ricetta che li nomina
|
||||
non si carica. Nessuna riga di algoritmo entra in questo repository.
|
||||
|
||||
*Fatto quando:* un grafo JSON che contiene `MatchPattern` e `CompareDXF` viene eseguito da
|
||||
`POST /api/vision/preview` su un'immagine caricata, e restituisce le uscite attese. Senza camera, senza
|
||||
interfaccia. E `test_vision_boundary.py` continua a passare.
|
||||
|
||||
### V2 — L'authoring nel Maker — **in fiera** (decisa il 14/09)
|
||||
|
||||
La schermata che oggi non esiste. Si compone con un **editor visuale a nodi** (decisione A, chiusa il
|
||||
14/09). Il Maker, alla scrivania:
|
||||
|
||||
- carica o sceglie un'**immagine di riferimento** per il task (l'API esiste già);
|
||||
- compone il grafo collegando gli strumenti — l'immagine entra, e da lì scendono il riconoscimento, le
|
||||
quote e il confronto col disegno;
|
||||
- preme **prova** e vede le uscite (`/preview` esiste già);
|
||||
- **aggancia** ciascuna uscita a una quota della ricetta, scrivendo `vision_output` sulla subtask (il
|
||||
campo esiste già), e su quella quota mette nominale e tolleranze come su qualunque altra;
|
||||
- **promuove le uscite della prova a `expected_json`**, che è ciò che accende la prova di non regressione
|
||||
(lacuna 4) e chiude una promessa che la spec di agosto ha fatto e non ha mantenuto.
|
||||
|
||||
**Il taglio per la fiera** (deciso il 14/09): ci va tutto **fino alla misura vera** — comporre, provare,
|
||||
agganciare alle quote, e il ciclo dell'operatore che misura e riceve OK/warning/fail con le tolleranze
|
||||
applicate. È la storia che vende: il confronto DXF **dentro il flusso di produzione**, non un tool a sé.
|
||||
Restano fuori dalla fiera, e si fanno dopo, la promozione a `expected_json` e la prova di non regressione:
|
||||
valgono per la robustezza, ma allo stand non si mostrano.
|
||||
|
||||
⚠️ **Un limite dell'editor, emerso il 14/09 costruendo gli strumenti.** Negli strumenti geometrici di
|
||||
`vs-task` gli ingressi di scansione — `start_row`, `start_col`, `end_row`, `end_col` di `DetectLine` e
|
||||
compagnia — **non sono collegabili**: si impostano a mano. Finché restano così, la posa che
|
||||
`MatchPattern` trova non può spostare le scansioni delle quote, e il pezzo va appoggiato dove la ricetta
|
||||
si aspetta. Renderli collegabili è un lavoro in VisionSuite, piccolo ma non nullo, ed è **il passo che
|
||||
rende utile il riconoscimento dentro una ricetta di misura**: senza, `MatchPattern` serve a dire se il
|
||||
pezzo c'è ed è quello giusto, non a inseguirlo.
|
||||
|
||||
*Fatto quando:* una ricetta con un task di visione si compone interamente dall'interfaccia, senza scrivere
|
||||
JSON a mano, e un operatore la esegue ottenendo un esito vero. La prova di non regressione — rieseguire
|
||||
l'intero parco ricette su immagini di riferimento e vedere quali quote si sono mosse — è il criterio della
|
||||
parte differita.
|
||||
|
||||
### V3 — L'agente di stazione, i device, la camera
|
||||
|
||||
È il cammino critico per Tràfilo, perché è ciò che rende il sistema utilizzabile in reparto.
|
||||
|
||||
- Tabella `station_devices` e migrazione, così com'è descritta nella spec di agosto (specchio in sola
|
||||
lettura, popolato dall'agente; un device che non si dichiara più è **offline**, non cancellato).
|
||||
- WebSocket `/api/stations/agent`: **è l'agente che apre la connessione**, il server non bussa mai a un PC
|
||||
di reparto. È la scelta che evita di aprire porte in fabbrica.
|
||||
- L'agente: sottile, monta `vs-core` e `vs-camera` e basta; acquisisce e carica il fotogramma. Camera
|
||||
**Itala** di Opto Engineering, il cui producer GenTL è già stato provato in VisionSuite e ha richiesto
|
||||
mezz'ora invece delle sei-dieci ore stimate.
|
||||
- `vision_results.image_path` e `overlay_path` finalmente scritte (lacuna 3), con l'overlay prodotto dal
|
||||
worker.
|
||||
|
||||
*Fatto quando:* un operatore misura un pezzo da una postazione vera, e la riga in `vision_results` porta
|
||||
immagine, overlay, device, taratura e versione del motore.
|
||||
|
||||
### V4 — L'esecuzione sulla stazione
|
||||
|
||||
La policy per stazione, il ripiego in locale quando il server non risponde, e il **rifiuto di misurare**
|
||||
quando la versione del motore non concorda con quella del server. Quest'ultimo non è un dettaglio di
|
||||
build: è la garanzia metrologica su cui poggia tutto il resto.
|
||||
|
||||
*Fatto quando:* una stazione con un pin discorde si rifiuta di misurare e lo dichiara, invece di produrre
|
||||
numeri plausibili.
|
||||
|
||||
### V5 — Il task `dxf_compare` come tipo a sé
|
||||
|
||||
Il valore `dxf_compare` esiste già nell'enum. Qui prende il suo comportamento: quote sintetiche derivate
|
||||
dal confronto (scarto massimo, numero di zone fuori tolleranza) trattate come misure normali, e il
|
||||
**rapporto allegato** che `visionsuite.dxf.report` sa già generare, archiviato accanto all'immagine.
|
||||
|
||||
## 7. Le decisioni che restano da prendere
|
||||
|
||||
Nessuna di queste si può dedurre dal codice: vanno chiuse da Adriano, alcune con il cliente.
|
||||
|
||||
**A. Come si compone un grafo — chiusa il 14/09/2026: un editor visuale a nodi.** Adriano lo ha rimesso
|
||||
nel perimetro, e la scelta **revoca la revoca del 09/08**. Allora TieWorkflow era uscito perché *«un editor
|
||||
ha senso solo se a comporre le pipeline è qualcuno che non programma, e il cliente aveva chiesto misure,
|
||||
non un editor»*: motivazione giusta quando il committente era Tràfilo, che vuole misurare profilati.
|
||||
Adesso il modulo è un investimento per prendere lavori futuri (decisione F), e il pubblico dello stand non
|
||||
è l'operatore di Tràfilo — è chi allo stand deve immaginarci il **proprio** pezzo, e a quello un form fisso
|
||||
non parla.
|
||||
|
||||
Le altre due strade restano scritte qui perché sono la via d'uscita, non perché siano state scelte: un
|
||||
**modulo per tipo di task** con i parametri in chiaro (il minimo che funziona) e una **libreria di grafi
|
||||
preconfezionati** che il Maker sceglie e tara.
|
||||
|
||||
⚠️⚠️ **È la più cara delle tre, su un cammino critico già pieno.** Ad agosto l'editor era stimato **12-18
|
||||
ore su un perimetro più piccolo** — senza `MatchPattern`, senza `CompareDXF`, senza l'aggancio quota→uscita
|
||||
— ed è l'ordine di grandezza che aveva fatto sfondare il budget del piano ferie. Quelle ore ora si sommano
|
||||
alla V1, anch'essa obbligatoria per la fiera, dentro 43 giorni. **Il ripiego va deciso adesso, non il 20
|
||||
ottobre:** se a metà ottobre l'editor non regge, si espone il modulo per tipo di task, che mostra le stesse
|
||||
misure con meno scena.
|
||||
|
||||
|
||||
**B. La scala: pixel o millimetri.** Il 09/08 si era deciso che le quote potessero restare **in pixel**, e
|
||||
che per il DXF compare servisse un fattore di scala ricavato da una quota nota misurata col calibro. Va
|
||||
confermato: sono due prodotti diversi da spiegare al cliente, e il secondo dipende dal test di
|
||||
calibrazione della fase V0.
|
||||
|
||||
**C. Il dimensionamento del server di Tràfilo.** Con l'esecuzione lato server come default, il pattern
|
||||
matching contende la CPU fra tutte le stazioni. La domanda alla D-4 non è più solo quale macchina e quanto
|
||||
disco: è anche quanti core e quanta RAM. Venti postazioni non sono tre.
|
||||
|
||||
**D. La conservazione delle immagini.** Quelle di riferimento sono poche e vivono quanto la ricetta;
|
||||
quelle di produzione sono una per pezzo e crescono senza limite. Serve una scadenza, o una regola del tipo
|
||||
«si conservano solo quelle dei pezzi fuori tolleranza». Senza, il disco del cliente si riempie e nessuno
|
||||
lo ha avvertito.
|
||||
|
||||
**E. Che cosa fa la stazione se la rete cade** e non è configurata per elaborare in locale: si blocca, o
|
||||
consente il calibro segnando il task come non eseguibile? È una domanda per Tràfilo.
|
||||
|
||||
**F. Chi paga — chiusa il 14/09/2026.** Nessuno, ed è una scelta. Il modulo di visione è un
|
||||
**investimento commerciale di Tielogic**: non si quota a Ricerca e Misure, non entra nel setup né nel
|
||||
canone dell'OFF-2026-022, e non aspetta un'offerta per cominciare. Serve a prendere lavori futuri, e il
|
||||
posto dove quell'investimento si trasforma in lavori è la fiera.
|
||||
|
||||
Ne discendono tre conseguenze che questo documento adotta:
|
||||
|
||||
1. **Il modulo non ha una data di consegna contrattuale**, quindi l'unica scadenza che lo governa è la
|
||||
fiera del 27-29 ottobre. La fase V1 è l'unica del percorso visione che ha una data vera.
|
||||
2. **Tràfilo va servito con il perimetro venduto** — le dodici modifiche del 28/07, l'installazione
|
||||
on-premise, l'agente di stazione — e quel lavoro non si rallenta per la visione: è quello che è già
|
||||
stato fatturato. Dove la visione e Tràfilo si contendono le stesse ore, vince ciò che è stato venduto,
|
||||
con l'unica eccezione della V1 prima della fiera.
|
||||
3. **Il confine va tenuto per iscritto**: le ore spese per la fiera sono a carico Tielogic, quelle spese
|
||||
per Tràfilo stanno nella commessa. Senza quella riga, a consuntivo il modulo di visione sembrerà un
|
||||
costo della commessa Ricerca e Misure e ne falserà il margine.
|
||||
|
||||
⚠️ Resta aperto un pezzo che non è «chi paga» ma «che cosa»: **cosa si mostra in fiera** non coincide con
|
||||
cosa si consegna. Una demo può girare su un grafo preparato, un pezzo noto e una posa controllata; un
|
||||
prodotto no. Deciderlo prima cambia l'ampiezza della V1.
|
||||
|
||||
## 8. Quello che non va fatto
|
||||
|
||||
- **Non scrivere algoritmi di visione in questo repository.** Il confine è sorvegliato da un test; se
|
||||
domani il server importasse VisionSuite per una scorciatoia, il vantaggio si perderebbe in silenzio.
|
||||
- **Non rifare l'interfaccia** oltre le schermate nuove che le fasi V2 e V3 richiedono.
|
||||
- **Non toccare SPC, ricette e integrazione GAIA.** GAIA è il gestionale del cliente finale e non è in
|
||||
questo perimetro.
|
||||
- **Non aggiungere il modulo AI** di cui si è parlato in call: è perimetro nuovo, senza offerta.
|
||||
- **Non far giudicare il grafo.** I verdetti di `vs-dxf` e di `vs-measure` restano inutilizzati: decide
|
||||
`measurement_service`.
|
||||
- **Non avanzare il pin del sottomodulo senza la prova di non regressione in piedi.** Con il motore
|
||||
installato su due host, un aggiornamento non verificato sposta i numeri senza che nessuno se ne accorga
|
||||
fino alla contestazione.
|
||||
|
||||
## 9. Il calendario, per capire le priorità
|
||||
|
||||
La catena è **Tielogic produce → Ricerca e Misure (Menoncin) rivende → Tràfilo usa**, venti postazioni.
|
||||
Due scadenze esterne, e dal 14/09 non pesano più allo stesso modo.
|
||||
|
||||
- **Fiera del 27-29 ottobre**, banchetto Tielogic nella zona Opto Engineering. Le servono **V1 e V2
|
||||
insieme**, perché allo stand si mostra il prodotto e non una demo: comporre una ricetta di visione
|
||||
dall'interfaccia, misurare un pezzo, vedere l'esito. È l'unica data del percorso visione, ed è il posto
|
||||
dove l'investimento della decisione F si trasforma in lavori.
|
||||
- **Installazione a Tràfilo**, attesa per settembre. Le serve la **V3**, ed è **ciò che è stato
|
||||
fatturato**. ⚠️ Con V1 e V2 entrambe prima della fiera, la V3 resta indietro: è una scelta consapevole,
|
||||
ma la tempistica dell'installazione era stata data a Menoncin, quindi va **detta a lui** invece di
|
||||
scoperta da lui.
|
||||
|
||||
⚠️ Il rischio non è che manchi una delle due: è che l'editor a nodi si prenda le ore della V1. Il ripiego
|
||||
è scritto nella decisione A, e va guardato a **metà ottobre**, quando cambiare strada costa ancora poco.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,474 @@
|
||||
# Integrazione di VisionSuite in TieMeasureFlow — design
|
||||
|
||||
> Progetto: TieMeasureFlow V3.0.0 → V3.1.0
|
||||
> Data: 2026-08-16
|
||||
> Stato: design approvato, piano di implementazione da scrivere
|
||||
|
||||
## Perché
|
||||
|
||||
TieMeasureFlow oggi registra misure prese a mano, con il calibro USB o con il
|
||||
tastierino. Il passo successivo che il cliente chiede è misurare con una camera:
|
||||
acquisire l'immagine di un pezzo e ricavarne le quote, oppure confrontarne il
|
||||
profilo con il disegno DXF di riferimento.
|
||||
|
||||
La visione artificiale non va scritta qui. Tielogic ha già
|
||||
[VisionSuite](ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git), un
|
||||
monorepo di moduli riusabili il cui README indica esplicitamente TieMeasureFlow
|
||||
fra i consumatori previsti. Questo documento descrive come agganciarlo.
|
||||
|
||||
Il terreno è in parte già preparato. La migrazione `007_task_type` ha introdotto
|
||||
il tipo di task dichiarato e l'enum contiene fin da allora due valori che nessuno
|
||||
produce né consuma ancora:
|
||||
|
||||
```python
|
||||
TASK_TYPES = ("note", "measure", "drawing", "xf_compare", "camera_measure")
|
||||
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
||||
```
|
||||
|
||||
Questo lavoro li riempie.
|
||||
|
||||
## Che cosa entra e che cosa resta fuori
|
||||
|
||||
**Entra:** l'acquisizione da camera, l'esecuzione di un programma di visione su
|
||||
quell'immagine — pattern matching più misure, oppure pattern matching più
|
||||
confronto DXF — e l'ingresso dei risultati nel modello di misura che già esiste.
|
||||
|
||||
**Resta fuori, per ora:** i semafori USB. Sono previsti dall'architettura — è
|
||||
l'agente di stazione a possederli, insieme alle camere — ma non fanno parte di
|
||||
questa consegna. Il confine è disegnato perché ci entrino senza ristrutturazioni.
|
||||
|
||||
## Le otto decisioni
|
||||
|
||||
Sono le scelte prese in fase di brainstorming, con il motivo per cui sono state
|
||||
prese. Chi legge fra sei mesi ha bisogno del motivo più della scelta.
|
||||
|
||||
### 1. La visione gira sia sul server sia sulla stazione
|
||||
|
||||
Il motore è un componente unico distribuito in due posti: accanto al server e
|
||||
dentro l'agente di stazione. Questo dà la resilienza — se cade il collegamento,
|
||||
la stazione continua a misurare — e permette al server di elaborare in fase di
|
||||
produzione, di comporre ricette per conto del Maker e di rifare analisi su
|
||||
immagini archiviate.
|
||||
|
||||
È coerente con la decisione **D-3**, chiusa il 28/07: dati sul server, app di
|
||||
stazione su ogni PC.
|
||||
|
||||
### 2. Server e stazione montano la stessa identica versione di VisionSuite
|
||||
|
||||
Non è una raccomandazione di build: è un vincolo metrologico. Se il server monta
|
||||
un commit e una stazione ne monta un altro, lo stesso pezzo con lo stesso grafo
|
||||
può dare due numeri diversi, e nessuno se ne accorge finché non arriva una
|
||||
contestazione.
|
||||
|
||||
Da qui tre obblighi che il design deve garantire:
|
||||
|
||||
- VisionSuite entra come **sottomodulo git su un commit preciso**, lo stesso per
|
||||
entrambi — la forma che il README di VisionSuite prescrive
|
||||
- ogni misura porta scritto **con quale versione del motore** è stata prodotta
|
||||
- una stazione che monta una versione diversa da quella del server **si rifiuta
|
||||
di misurare** e lo dichiara, invece di produrre numeri plausibili e sbagliati
|
||||
|
||||
### 3. L'agente di stazione è sottile
|
||||
|
||||
L'agente parla ai device, consegna immagini e — più avanti — piloterà i semafori.
|
||||
Non contiene decisioni di visione.
|
||||
|
||||
La conseguenza pratica è l'impacchettamento: un agente che non contiene
|
||||
VisionSuite non contiene nemmeno PyTorch. Distribuirlo come eseguibile su Windows
|
||||
o come container su Linux diventa realistico, e la scelta su driver e formato può
|
||||
restare tardiva — che è bene, perché dipende dalla **D-4**, ancora aperta.
|
||||
|
||||
L'agente incorpora il motore solo quando la stazione è configurata per elaborare
|
||||
in locale, e in quel caso resta comunque un guscio: riceve un grafo, lo esegue,
|
||||
restituisce le uscite.
|
||||
|
||||
### 4. Il server non importa mai VisionSuite
|
||||
|
||||
La visione sul lato server vive in un **servizio separato** nello stesso compose,
|
||||
il *vision worker*. Il server FastAPI continua a fare quello che fa —
|
||||
autenticazione, ricette, misure, statistica — e resta leggero.
|
||||
|
||||
Quattro ragioni concrete: l'immagine dell'API non passa da poche centinaia di
|
||||
megabyte a cinque gigabyte; un aggiornamento di VisionSuite non richiede di
|
||||
riavviare l'API in produzione; un'elaborazione che va in crash non porta giù le
|
||||
richieste degli altri tablet; il giorno in cui la visione chiede una GPU o una
|
||||
macchina propria si sposta senza toccare il resto.
|
||||
|
||||
Il confine va difeso: se un domani il server importasse VisionSuite per una
|
||||
scorciatoia, il vantaggio si perde in silenzio.
|
||||
|
||||
### 5. La configurazione dei device sta sul PC
|
||||
|
||||
Le camere — e domani i semafori — appartengono alla macchina, non alla ricetta.
|
||||
Il server ne tiene uno **specchio** in sola lettura, popolato dall'agente quando
|
||||
si connette, perché il Maker deve poter scegliere un device da un elenco stando
|
||||
alla scrivania.
|
||||
|
||||
Un device che l'agente non dichiara più è **offline**, non cancellato: una
|
||||
ricetta che lo referenzia deve continuare a esistere e a spiegare perché non può
|
||||
girare.
|
||||
|
||||
### 6. Dove si esegue lo decide la configurazione della stazione
|
||||
|
||||
Ogni PC dichiara se elabora in locale o delega al server, nella stessa
|
||||
configurazione dove già stanno i device. Il default è il server.
|
||||
|
||||
Le macchine capaci si prendono il carico e non mandano immagini in rete; quelle
|
||||
deboli delegano. Se il server non risponde e la stazione ha il motore, la
|
||||
stazione ripiega in locale e lo segna nel risultato. Un impianto misto si governa
|
||||
senza toccare le ricette.
|
||||
|
||||
La ricetta non dice mai dove gira: legare una scelta di impianto a un documento
|
||||
di produzione significa dover rimettere mano alle ricette per spostare il carico.
|
||||
|
||||
### 7. Il verdetto lo dà TieMeasureFlow, non il grafo
|
||||
|
||||
Gli strumenti di quota di `vs-task` hanno anche loro `nominal`, `plus`, `minus` e
|
||||
un `outcome`. Quell'esito viene ignorato e quei limiti restano vuoti: il grafo
|
||||
produce numeri, `measurement_service` decide se sono buoni.
|
||||
|
||||
Due giudici sullo stesso valore è il modo più rapido per ottenere un CSV che si
|
||||
contraddice e una contestazione che non si sa come chiudere.
|
||||
|
||||
La conseguenza è che tutto il resto continua a funzionare senza modifiche:
|
||||
pass/warning/fail, gate del fuori tolleranza con autorizzazione del capoturno,
|
||||
Cp/Cpk, carta di controllo, colonne del CSV di produzione. Una quota da camera è
|
||||
una misura come le altre.
|
||||
|
||||
### 8. La sorgente immagine è un'astrazione a tre facce
|
||||
|
||||
Il motore riceve un'immagine e non sa da dove venga. Le tre sorgenti sono: dal
|
||||
vivo da un device tramite l'agente, un'immagine di riferimento salvata sul
|
||||
server, un file caricato dal Maker.
|
||||
|
||||
Non è un'astrazione nostra: è quella che VisionSuite si è già data — *«espongono
|
||||
una sorgente, non una telecamera: è questo che permette di collaudare un impianto
|
||||
a cinque telecamere senza avere le cinque telecamere sul tavolo, e di scrivere
|
||||
prove che danno sempre lo stesso risultato»*.
|
||||
|
||||
Sblocca tre cose che altrimenti non ci sarebbero:
|
||||
|
||||
- il Maker compone alla scrivania, senza tenere occupata una stazione né avere il
|
||||
pezzo in mano
|
||||
- una ricetta si prova prima di andare in produzione, invece di scoprirne i
|
||||
difetti misurando
|
||||
- diventa possibile una **prova di non regressione**: salvando accanto
|
||||
all'immagine il risultato atteso, si riesegue l'intero parco ricette dopo un
|
||||
aggiornamento di VisionSuite e si vede quali quote si sono mosse
|
||||
|
||||
Quest'ultima è la rete di sicurezza che rende sostenibile aggiornare il
|
||||
sottomodulo, con un motore che sta su due host.
|
||||
|
||||
> **Non ancora consegnata.** La colonna `expected_json` esiste, ma nessun
|
||||
> percorso la scrive — nemmeno `preview`, che è l'unico posto dove un grafo gira
|
||||
> davvero su un'immagine. La prova di non regressione è quindi **promessa da
|
||||
> questo documento e non mantenuta da nessuna riga di codice**. Diventa una
|
||||
> consegna nominata del piano 1b: «`preview` può promuovere le proprie uscite a
|
||||
> `expected_json`». Finché non è fatta, questa decisione non va dichiarata
|
||||
> completa.
|
||||
|
||||
## Architettura
|
||||
|
||||
### I componenti
|
||||
|
||||
| Componente | Nuovo | Dove vive | Responsabilità |
|
||||
|---|---|---|---|
|
||||
| `vendor/visionsuite` | sì | sottomodulo git | i pacchetti `vs-core`, `vs-camera`, `vs-pm2d`, `vs-measure`, `vs-dxf`, su un commit fisso |
|
||||
| Vision runner | sì | libreria condivisa | immagine + grafo + calibrazione → quote e artefatti. Unica implementazione, due host |
|
||||
| Vision worker | sì | container nel compose | espone il runner dietro una API interna. Il container pesante |
|
||||
| Agente di stazione | sì | PC di reparto | possiede i device, acquisisce, apre la connessione verso il server, incorpora il runner se configurato |
|
||||
| Backend FastAPI | modificato | container esistente | tipi di task, grafo sul task, registro device, inoltro, ingresso risultati. **Nessun import di VisionSuite** |
|
||||
| Frontend Flask | modificato | container esistente | Maker: device, fotogramma, aggancio quote. Measure: schermata del task con camera |
|
||||
|
||||
L'aggancio del sottomodulo segue la forma prescritta dal README di VisionSuite:
|
||||
dipendenza dai soli pacchetti che servono, dichiarati in path editable.
|
||||
|
||||
```toml
|
||||
[tool.uv.sources]
|
||||
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
|
||||
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true }
|
||||
```
|
||||
|
||||
I due host non montano lo stesso insieme, ed è il punto dell'intera struttura di
|
||||
VisionSuite:
|
||||
|
||||
| Pacchetto | Worker | Agente | Perché |
|
||||
|---|---|---|---|
|
||||
| `vs-core` | sì | sì | strutture di base e calibrazione, servono a entrambi |
|
||||
| `vs-pm2d` | sì | solo se elabora in locale | pattern matching |
|
||||
| `vs-measure` | sì | solo se elabora in locale | primitive e quote |
|
||||
| `vs-dxf` | sì | solo se elabora in locale | confronto col disegno. È quello che porta PyTorch |
|
||||
| `vs-camera` | **no** | sì | il worker non tocca hardware: riceve immagini già acquisite |
|
||||
|
||||
Un agente su una stazione che delega al server monta quindi due soli pacchetti,
|
||||
`vs-core` e `vs-camera`, e resta leggero. È la differenza fra un eseguibile
|
||||
distribuibile e un'installazione da cinque gigabyte su ogni PC di reparto.
|
||||
|
||||
I nomi di importazione non sono uniformi — `vs-core` si importa come
|
||||
`visionsuite`, `vs-pm2d` come `pm2d`, `vs-dxf` come `dxf_compare` — ed è una
|
||||
stortura nota e dichiarata a monte, non un errore da correggere qui.
|
||||
|
||||
### Il flusso di una misura, esecuzione sul server
|
||||
|
||||
```
|
||||
operatore apre il task
|
||||
→ client chiede al server di eseguire
|
||||
→ server chiede all'agente un fotogramma sul device dichiarato
|
||||
→ agente acquisisce e carica l'immagine
|
||||
→ server passa immagine e grafo al worker
|
||||
→ worker restituisce le uscite
|
||||
→ server applica pass/fail, gate del fuori tolleranza, salva in measurements
|
||||
→ client mostra esito e immagine con le zone dove ha guardato
|
||||
```
|
||||
|
||||
### Il flusso con esecuzione in locale
|
||||
|
||||
Il giro si accorcia: il server manda il grafo all'agente, l'agente acquisisce ed
|
||||
esegue, e rimanda uscite più immagine. **Da lì in poi il server fa le stesse
|
||||
identiche cose.** Il verdetto, il gate e il salvataggio non cambiano mai posto:
|
||||
cambia chi calcola i numeri, non chi decide se sono buoni.
|
||||
|
||||
È questo che rende le due strade equivalenti a valle, e che permette di cambiare
|
||||
la configurazione di una stazione senza toccare né il frontend né le ricette.
|
||||
|
||||
## Modello dati
|
||||
|
||||
### Il grafo di visione
|
||||
|
||||
`recipe_tasks` prende **`vision_json`** (JSON, nullable): il grafo di `vs-task`
|
||||
serializzato, opaco al server.
|
||||
|
||||
Il versionamento arriva gratis. Il copy-on-write delle ricette copia già i task
|
||||
in profondità, quindi una modifica al grafo su una ricetta con misure produce una
|
||||
versione nuova come qualunque altra modifica; in `recipe_service` è un campo in
|
||||
più nella copia, non una logica nuova.
|
||||
|
||||
Non si riusa `annotations_json`, che sta lì accanto: le annotazioni sono un
|
||||
disegno per l'operatore, il grafo è un programma da eseguire. Hanno cicli di vita
|
||||
e regole di validazione diversi, e mescolarli significa non poter validare né
|
||||
l'uno né l'altro.
|
||||
|
||||
### Il legame fra quota del grafo e quota della ricetta
|
||||
|
||||
`recipe_subtasks` prende **`vision_output`** (String, nullable): la coppia
|
||||
«identificativo dello strumento, nome della sua uscita».
|
||||
|
||||
È la stessa forma di legame che `vs-task` usa internamente, e per la stessa
|
||||
ragione dichiarata nel suo README: *«Il legame è un ID, non un puntatore»* — un
|
||||
riferimento in memoria non si scrive su disco.
|
||||
|
||||
Tutto il resto della subtask — nominale, UTL, UWL, LWL, LTL, unità — resta com'è
|
||||
e continua a significare quello che significa oggi.
|
||||
|
||||
### Le misure
|
||||
|
||||
`input_method` passa da `("usb_caliper", "manual")` a
|
||||
`("usb_caliper", "manual", "camera")`.
|
||||
|
||||
Il contorno della visione **non** va su `measurements`. Quella tabella la leggono
|
||||
la statistica e l'export a ogni giro, e appesantirla la rovina. Ma soprattutto
|
||||
sarebbe sbagliato di modello: una sola acquisizione produce N quote, e immagine,
|
||||
overlay, device e versione del motore sono gli stessi per tutte.
|
||||
|
||||
Nasce quindi **`vision_results`**, una riga per **esecuzione**:
|
||||
|
||||
| Campo | Perché c'è |
|
||||
|---|---|
|
||||
| `image_path`, `overlay_path` | l'immagine, e quella con sopra le zone dove ha guardato |
|
||||
| `engine_version` | il commit di VisionSuite che ha prodotto i numeri |
|
||||
| `executed_on` | `server` o `station` |
|
||||
| `station_id`, `device_id` | da quale macchina e quale camera |
|
||||
| `calibration_snapshot` | la taratura in vigore in quel momento, **copiata** |
|
||||
| `graph_snapshot` | il grafo effettivamente eseguito, **copiato** |
|
||||
| `duration_ms`, `executed_at` | quanto è costata, quando |
|
||||
|
||||
Su `measurements` una sola colonna nuova: `vision_result_id`, FK nullable.
|
||||
|
||||
Calibrazione e grafo si copiano invece di essere referenziati per la stessa
|
||||
ragione: una taratura rifatta sei mesi dopo non deve riscrivere il significato di
|
||||
una misura già presa.
|
||||
|
||||
### Il registro dei device
|
||||
|
||||
**`station_devices`**: `station_id`, `code` (l'identificativo locale), `kind`
|
||||
(`camera` oggi, `light` domani), `label`, `capabilities` (JSON), `calibration`
|
||||
(JSON) con `calibration_taken_at`, `last_seen_at`.
|
||||
|
||||
Si popola dall'agente. Il server lo riceve e non lo modifica.
|
||||
|
||||
### Le immagini di riferimento
|
||||
|
||||
**`vision_reference_images`**: `task_id`, `path`, e la provenienza per intero —
|
||||
`station_id`, `device_id`, snapshot della calibrazione, `engine_version`,
|
||||
`acquired_at`, una nota — più `expected_json` per l'atteso della prova di non
|
||||
regressione.
|
||||
|
||||
La provenienza non è rimandabile. Senza, si riesegue il grafo mesi dopo, escono
|
||||
numeri diversi, e non c'è modo di distinguere un motore aggiornato da un'ottica
|
||||
spostata.
|
||||
|
||||
### Migrazione 011
|
||||
|
||||
Cinque cose: `vision_json` su `recipe_tasks`, `vision_output` su
|
||||
`recipe_subtasks`, `camera` in `input_method_enum`, `vision_result_id` su
|
||||
`measurements`, e le tre tabelle nuove.
|
||||
|
||||
Più una sesta, **da fare adesso o mai**: `task_type_enum` contiene `xf_compare`,
|
||||
refuso per `dxf_compare`. Oggi nessuna riga usa quel valore e correggerlo costa
|
||||
una riga. Dal primo task salvato in poi diventa una migrazione di dati, e il
|
||||
refuso finisce nelle API pubbliche.
|
||||
|
||||
Come le migrazioni 008 e 010, va scritta in *batch mode* per la compatibilità
|
||||
SQLite dei test e verificata in resa MySQL con `alembic upgrade X:Y --sql` prima
|
||||
del deploy: è la procedura che il progetto si è già dato.
|
||||
|
||||
## API
|
||||
|
||||
L'agente apre lui la connessione. **Il server non bussa mai a un PC**, e questo
|
||||
evita di dover aprire porte sulle macchine di reparto — che è materia della D-4.
|
||||
|
||||
| Verso | Endpoint | Cosa fa |
|
||||
|---|---|---|
|
||||
| agente → server | `WS /api/stations/agent` | si autentica con la chiave di stazione, dichiara i device, resta in ascolto |
|
||||
| server → agente | comando `acquire` | «scatta sul device X»; l'agente carica con `POST /api/vision/frames` |
|
||||
| server → agente | comando `execute` | esecuzione in locale: manda il grafo, riceve le uscite |
|
||||
| client → server | `POST /api/vision/execute` | esegui il task per la produzione in corso; **il server sceglie dove** |
|
||||
| client → server | `POST /api/vision/preview` | authoring: esegui su una sorgente qualsiasi, non salvare nulla |
|
||||
| client → server | `GET /api/stations/{code}/devices` | l'elenco per il Maker |
|
||||
| client → server | `POST /api/vision/reference-images` | salva un fotogramma come riferimento |
|
||||
| server → worker | `POST /run` | interno, mai esposto: immagine e grafo → uscite e overlay |
|
||||
|
||||
Un solo endpoint decide dove si esegue, ed è `execute`. Il client non sa e non
|
||||
deve sapere se ha misurato il server o la stazione: chiede una misura e riceve un
|
||||
esito.
|
||||
|
||||
## Errori e casi limite
|
||||
|
||||
| Caso | Comportamento |
|
||||
|---|---|
|
||||
| Versione del motore diversa fra stazione e server | la stazione si rifiuta di misurare e lo dichiara. Mai produrre numeri con un motore non concorde |
|
||||
| Agente non raggiungibile | il task di visione non è eseguibile e lo dice. Da decidere con il cliente se la stazione debba consentire il ripiego sul calibro |
|
||||
| Server non raggiungibile, stazione con motore | la stazione esegue in locale e lo segna in `executed_on` |
|
||||
| Device dichiarato dalla ricetta ma offline | la ricetta resta valida, il task spiega perché non può girare |
|
||||
| Il grafo non produce un'uscita attesa da una subtask | la misura non si salva, l'errore nomina l'uscita mancante |
|
||||
| Elaborazione in crash o oltre il tempo | il worker isola il guasto, l'API resta in piedi, il task riporta l'errore |
|
||||
| Device senza calibrazione | le quote in millimetri non si producono. Mai convertire con un fattore implicito |
|
||||
|
||||
## Prove
|
||||
|
||||
Ai tre file di test che il progetto ha già e che leggono i sorgenti invece di
|
||||
renderizzare — `test_offline.py`, `test_layout_shell.py`,
|
||||
`test_template_js_syntax.py` — se ne aggiunge la stessa specie:
|
||||
|
||||
- **una prova che il server non importi VisionSuite.** È il confine su cui poggia
|
||||
la decisione 4, e senza qualcuno che lo controlli si perde in silenzio
|
||||
- **una prova che il commit del sottomodulo dichiarato coincida** con quello che
|
||||
server e agente montano
|
||||
|
||||
Per il resto, il runner si prova su immagini di riferimento salvate — che è
|
||||
precisamente ciò per cui l'astrazione della sorgente esiste. Nessuna prova
|
||||
richiede una camera collegata.
|
||||
|
||||
## Quello che questo design non risolve
|
||||
|
||||
- **Il dimensionamento del server.** Il pattern matching è pesante e, con
|
||||
l'esecuzione lato server come default, la CPU è contesa fra tutte le stazioni.
|
||||
Va aggiunto alla **D-4**, che finora chiedeva solo quale macchina e quanto
|
||||
disco: ora chiede anche quanti core e quanta RAM.
|
||||
- **La conservazione delle immagini.** Due politiche distinte, entrambe da
|
||||
portare in D-4: le immagini di riferimento sono poche e vivono quanto la
|
||||
ricetta; quelle di produzione sono una per pezzo e crescono senza limite, e
|
||||
vogliono una scadenza o una regola del tipo «si conservano solo quelle dei
|
||||
pezzi fuori tolleranza».
|
||||
- **Che cosa fa la stazione se la rete cade** e non è configurata per elaborare
|
||||
in locale: si blocca, o consente il calibro e segna il task come non
|
||||
eseguibile? È una domanda per il cliente.
|
||||
- **L'impacchettamento dell'agente.** Le macchine possono essere Linux o Windows
|
||||
e i driver possono stare sulla macchina o nel container. La decisione è
|
||||
volutamente tardiva; il design la rende tale tenendo l'agente sottile e neutro.
|
||||
|
||||
## Correzioni imposte dall'esecuzione
|
||||
|
||||
Il piano derivato da questo documento è stato eseguito il 16/08/2026. Sei cose
|
||||
che qui erano scritte male o taciute, corrette dai fatti.
|
||||
|
||||
### Il gate del fuori tolleranza vale per l'acquisizione, non per la quota
|
||||
|
||||
La decisione 7 e il testo del punto 5 parlano di «la quota successiva». Quella
|
||||
regola era stata pensata per la misura **manuale e sequenziale**, dove esiste
|
||||
davvero un momento successivo in cui l'operatore potrebbe tirare avanti con una
|
||||
lettura cattiva irrisolta.
|
||||
|
||||
Una camera produce tutte le N quote **nello stesso istante**. Dentro
|
||||
un'acquisizione non esiste un «tirare avanti», quindi lì non c'è nulla da
|
||||
presidiare — e applicare la regola sequenziale a un evento simultaneo produceva
|
||||
un blocco permanente: la lettura fuori tolleranza non veniva mai registrata,
|
||||
quindi nessuno poteva autorizzarla, e rifare la stessa foto ripeteva l'errore.
|
||||
|
||||
Il gate si valuta **una volta per acquisizione**: se la misura pendente è fra le
|
||||
quote che questa acquisizione riprodurrà non blocca — riacquisire *è* rimisurare
|
||||
quelle quote, che questo documento già dichiara la via d'uscita che non richiede
|
||||
il capoturno. Fra due acquisizioni, e alla chiusura del ciclo, blocca come prima.
|
||||
|
||||
### Il repository è deliberatamente bi-ambiente
|
||||
|
||||
`vs-task`, `vs-measure` e `vs-pm2d` dichiarano `requires-python >= 3.13`;
|
||||
`vs-core` si ferma a 3.10. Backend e frontend restano su **3.11**, l'albero della
|
||||
visione gira su **3.13**, e l'extra `vision` porta i marker d'ambiente che
|
||||
tengono separate le due risoluzioni.
|
||||
|
||||
Questo rafforza la decisione 4 più di come era argomentata: con VisionSuite dentro
|
||||
il server FastAPI, questa scoperta avrebbe costretto l'intero backend a 3.13.
|
||||
|
||||
### La versione del motore si timbra al build
|
||||
|
||||
`vendor/visionsuite/.git` è un file che rimanda a `../../.git/modules/...`, che
|
||||
nel contesto di build non esiste: interrogare git dentro il container non
|
||||
funziona. `engine_version()` legge `VISION_ENGINE_VERSION` dall'ambiente, ripiega
|
||||
su git nei checkout di sviluppo, e **alza un errore** se non risolve né l'uno né
|
||||
l'altro. Il build fallisce se la variabile manca.
|
||||
|
||||
### La divisione degli extra compra esplicitezza, non peso
|
||||
|
||||
Gli extra sono due — `vision` per il runner, `vision-worker` per il servizio web
|
||||
— ed è la forma giusta. Ma la motivazione che sembrava ovvia è falsa: `vs-pm2d`
|
||||
dichiara **di suo** `fastapi`, `uvicorn`, `python-multipart` e `pillow`, quindi
|
||||
un agente di stazione che fa pattern matching se li porterà dietro comunque. La
|
||||
correzione vera è a monte — una libreria non dovrebbe dipendere da un server web
|
||||
— e va aggiunta alle verifiche su VisionSuite qui sotto.
|
||||
|
||||
### L'immagine di produzione non viene ancora conservata
|
||||
|
||||
`vision_results.image_path` e `overlay_path` migrano e restano `NULL`: nulla le
|
||||
scrive. Il flusso descritto in questo documento si chiude con «il client mostra
|
||||
esito e immagine», e finché quelle colonne sono vuote una misura contestata non
|
||||
ha prova materiale. Consegna nominata del piano 1b, insieme all'overlay.
|
||||
|
||||
### Prima di applicare le migrazioni in produzione
|
||||
|
||||
La 011 rinomina un valore di enum su una tabella in esercizio. Va contato prima
|
||||
che nessuna riga usi `xf_compare`: in strict mode una riga sopravvissuta fa
|
||||
fallire l'`ALTER` a metà deploy, fuori strict mode diventa `''` in silenzio.
|
||||
Attenzione anche alla finestra: il `MODIFY` su `recipe_tasks` è una rinomina di
|
||||
valore e userà `ALGORITHM=COPY`, mentre quello su `measurements.input_method`
|
||||
aggiunge in coda e dovrebbe restare `INPLACE` — da confermare sulle dimensioni
|
||||
reali, perché `measurements` è la tabella grossa.
|
||||
|
||||
## Da verificare prima di cominciare
|
||||
|
||||
VisionSuite è stato lavorato fino al 15/08/2026 e il suo ultimo commit è
|
||||
*«Rassegna delle firme pubbliche prima che si congelino»*: le API si stanno
|
||||
stabilizzando proprio ora. Prima di fissare il commit del sottomodulo:
|
||||
|
||||
- **il suo README è indietro rispetto al codice.** Dichiara `vs-measure` «da
|
||||
scrivere» quando invece esiste, con circa 4.865 righe e tredici file di test, e
|
||||
dichiara `vs-dxf` non avviabile quando l'import di `common.calibration` non c'è
|
||||
più. Va riletto lo stato reale, non quello documentato.
|
||||
- **il test di calibrazione dato per rosso** — la focale recuperata a 373 contro
|
||||
456 attesi, con tolleranza al 5% — va verificato sullo stato attuale. Il README
|
||||
di VisionSuite avverte di guardarlo *prima* di costruirci sopra misure in
|
||||
millimetri, che è esattamente ciò che questo lavoro fa. Le calibrazioni sono
|
||||
state toccate il 13/08 e lo stato potrebbe essere cambiato.
|
||||
- **il peso dell'ambiente.** `vs-dxf` dipende da PyTorch e l'installazione
|
||||
completa arriva a circa 5 GB. È la ragione per cui il worker è un container a
|
||||
parte, e va confermato che il solo sottoinsieme necessario pesi meno.
|
||||
+8
-9
@@ -34,13 +34,6 @@ server {
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# Upload files served by server
|
||||
location /uploads/ {
|
||||
proxy_pass http://server_backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# All other requests → Flask client
|
||||
location / {
|
||||
proxy_pass http://client_frontend;
|
||||
@@ -50,12 +43,18 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Static file caching
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||
# Static file caching. Limitata a /static/: un add_header in questo
|
||||
# location spegne l'ereditarietà di quelli del blocco server (comportamento
|
||||
# nginx), quindi vanno ripetuti qui.
|
||||
location ~* ^/static/.*\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
|
||||
proxy_pass http://client_frontend;
|
||||
proxy_set_header Host $host;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
|
||||
# SSL configuration (uncomment when using Let's Encrypt)
|
||||
|
||||
+38
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "tiemeasureflow"
|
||||
version = "2.0.0"
|
||||
version = "3.0.0"
|
||||
description = "TieMeasureFlow by Tielogic — manual caliper measurement task management for industrial QA stations."
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
@@ -53,6 +53,34 @@ dev = [
|
||||
"coverage>=7.0.0",
|
||||
]
|
||||
|
||||
# Vision runner (VisionSuite submodule): what src/vision/runner.py needs to
|
||||
# execute a graph. vs-task/vs-measure/vs-pm2d require Python >=3.13 upstream;
|
||||
# marker keeps the base 3.11 resolution untouched. numpy is declared
|
||||
# explicitly - runner.py imports it directly, and it must not depend on
|
||||
# being pulled in as a side effect of vs-pm2d's own dependencies.
|
||||
vision = [
|
||||
"visionsuite; python_version>='3.13'",
|
||||
"vs-task; python_version>='3.13'",
|
||||
"vs-measure; python_version>='3.13'",
|
||||
"vs-pm2d; python_version>='3.13'",
|
||||
# #46: tutti gli strumenti della suite (locate/MatchPattern e dxf/CompareDxf)
|
||||
"vs-tools[locate,dxf]; python_version>='3.13'",
|
||||
"numpy; python_version>='3.13'",
|
||||
]
|
||||
|
||||
# Vision worker (src/vision_worker/): the FastAPI shell around the runner,
|
||||
# on top of `vision`. Kept separate on purpose (Ruling R9) - VisionSuite's
|
||||
# whole structure exists so a consumer installs only what it needs, and a
|
||||
# later station agent embeds the same runner without being a web service.
|
||||
# It must not drag fastapi/uvicorn onto shop-floor PCs.
|
||||
vision-worker = [
|
||||
"tiemeasureflow[vision]",
|
||||
"fastapi>=0.110.0; python_version>='3.13'",
|
||||
"uvicorn[standard]>=0.30.0; python_version>='3.13'",
|
||||
"pillow>=10.0.0; python_version>='3.13'",
|
||||
"python-multipart>=0.0.6; python_version>='3.13'",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
# Backend
|
||||
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
|
||||
@@ -70,6 +98,14 @@ packages = ["src/backend", "src/frontend"]
|
||||
# Pin the resolver to the deps we declared; reproducible builds.
|
||||
package = false
|
||||
|
||||
[tool.uv.sources]
|
||||
visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
|
||||
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
|
||||
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
|
||||
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
|
||||
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
|
||||
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["src/backend/tests", "src/frontend/flask_app/tests"]
|
||||
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."""
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.services.auth_service import hash_api_key
|
||||
from src.backend.utils.tempo import utcnow
|
||||
|
||||
_RENEW_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
@@ -24,15 +32,25 @@ async def get_current_user(
|
||||
)
|
||||
|
||||
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(
|
||||
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
|
||||
request.state.user_id = user.id
|
||||
|
||||
@@ -6,9 +6,14 @@ from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from sqlalchemy import insert
|
||||
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
from src.backend.database import async_session_factory
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
|
||||
# AccessLog.action is String(100): a long path (with query-string-like
|
||||
# segments) silently failed the insert before this truncation existed.
|
||||
_ACTION_MAX_LENGTH = 100
|
||||
|
||||
|
||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that logs every API request to the access_logs table."""
|
||||
@@ -30,11 +35,12 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# Log asynchronously (don't block response)
|
||||
try:
|
||||
action = f"{request.method} {request.url.path}"[:_ACTION_MAX_LENGTH]
|
||||
async with async_session_factory() as session:
|
||||
await session.execute(
|
||||
insert(AccessLog).values(
|
||||
user_id=user_id,
|
||||
action=f"{request.method} {request.url.path}",
|
||||
action=action,
|
||||
details={
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
@@ -42,7 +48,7 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
"status_code": response.status_code,
|
||||
"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],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Rate limiting middleware for FastAPI.
|
||||
|
||||
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
|
||||
from collections import defaultdict
|
||||
from typing import Callable
|
||||
@@ -17,14 +19,32 @@ from src.backend.config import settings
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware that enforces per-IP rate limits using a sliding window.
|
||||
|
||||
- Login endpoint (/api/auth/login): limited to `rate_limit_login` req/min.
|
||||
- Login bucket: `/api/auth/login`, the paths that carry a capoturno's
|
||||
credentials (`/api/measurements/*/authorise`,
|
||||
`/api/production-runs/{id}/pause|resume|close`), and everything under
|
||||
`/api/setup/` (the setup password). Limited to `rate_limit_login`
|
||||
req/min. Guessing a supervisor's or the setup password is the same
|
||||
attack as guessing a login password, just aimed at a different endpoint.
|
||||
- All other endpoints: limited to `rate_limit_general` req/min.
|
||||
Returns HTTP 429 with Retry-After header when limit exceeded.
|
||||
|
||||
Both configured limits are divided across worker processes before being
|
||||
applied (see `_effective_limit`): each bucket lives in this process's
|
||||
memory, so with `--workers N` a single configured limit is enforced N
|
||||
times over across the cluster unless it is divided down first.
|
||||
"""
|
||||
|
||||
LOGIN_PATH = "/api/auth/login"
|
||||
WINDOW_SECONDS = 60
|
||||
|
||||
# Paths that carry a supervisor's (capoturno) credentials in the request
|
||||
# body. Matched by pattern, not equality, because the id in the middle
|
||||
# varies per request.
|
||||
_SUPERVISOR_CREDENTIAL_PATTERNS = (
|
||||
re.compile(r"^/api/measurements/\d+/authorise$"),
|
||||
re.compile(r"^/api/production-runs/\d+/(?:pause|resume|close)$"),
|
||||
)
|
||||
|
||||
def __init__(self, app) -> None:
|
||||
super().__init__(app)
|
||||
# {ip: [timestamp, ...]} per bucket
|
||||
@@ -34,23 +54,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
@staticmethod
|
||||
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``,
|
||||
``request.client.host``. Required because Nginx and the Flask client
|
||||
sit between the tablet and the API; without parsing these headers
|
||||
every tablet shares one bucket.
|
||||
Nginx (and, behind it, the Flask client) each add one hop to
|
||||
X-Forwarded-For. The *leftmost* entries are whatever the original
|
||||
caller claimed and are trivially spoofed; trust only extends to the
|
||||
hops actually appended by our own trusted proxies, counted from the
|
||||
*right*. With `trusted_proxy_count = N`, the client IP is the Nth
|
||||
element from the end of XFF (the one added by the outermost trusted
|
||||
proxy). If XFF has fewer elements than that, or is absent, fall back
|
||||
to `request.client.host` - except that `X-Real-IP` is tried first when
|
||||
XFF is simply missing and at least one proxy is trusted.
|
||||
|
||||
With `trusted_proxy_count = 0`, no proxy header is honored at all
|
||||
(neither XFF nor X-Real-IP) and only `request.client.host` is used.
|
||||
"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
first = xff.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
real = request.headers.get("x-real-ip")
|
||||
if real:
|
||||
return real.strip()
|
||||
count = settings.trusted_proxy_count
|
||||
if count > 0:
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
hops = [h.strip() for h in xff.split(",") if h.strip()]
|
||||
if len(hops) >= count:
|
||||
return hops[-count]
|
||||
# Fewer hops than trusted proxies: the chain is short of what
|
||||
# was configured, so fall back rather than trust an
|
||||
# under-populated header.
|
||||
else:
|
||||
real = request.headers.get("x-real-ip")
|
||||
if real:
|
||||
return real.strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
@classmethod
|
||||
def _is_login_bucket_request(cls, method: str, path: str) -> bool:
|
||||
"""Whether this request counts against the login bucket rather than general.
|
||||
|
||||
`/api/setup/*` is checked regardless of method: `GET /api/setup/status`
|
||||
now accepts an optional password (see setup.py), so guessing it there
|
||||
is the same attack as guessing it on any of the POST endpoints.
|
||||
"""
|
||||
if path.startswith("/api/setup/"):
|
||||
return True
|
||||
if method != "POST":
|
||||
return False
|
||||
if path == cls.LOGIN_PATH:
|
||||
return True
|
||||
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
|
||||
|
||||
@staticmethod
|
||||
def _effective_limit(configured: int) -> int:
|
||||
"""Divide a configured per-minute limit across worker processes.
|
||||
|
||||
Rounds up and never returns less than 1, so a low configured limit
|
||||
with high concurrency still allows at least one request per worker
|
||||
per window rather than locking everyone out.
|
||||
"""
|
||||
concurrency = max(settings.web_concurrency, 1)
|
||||
return max(1, -(-configured // concurrency))
|
||||
|
||||
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
||||
"""Remove timestamps outside the current sliding window."""
|
||||
cutoff = now - self.WINDOW_SECONDS
|
||||
@@ -97,10 +158,12 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
self._evict_stale_ips(self._login_requests, now)
|
||||
self._evict_stale_ips(self._general_requests, now)
|
||||
|
||||
# Check login-specific rate limit
|
||||
if path == self.LOGIN_PATH and request.method == "POST":
|
||||
# Check login-bucket rate limit (login itself, supervisor-credential
|
||||
# paths, and the setup password)
|
||||
if self._is_login_bucket_request(request.method, path):
|
||||
allowed, retry_after = self._check_rate_limit(
|
||||
self._login_requests, client_ip, settings.rate_limit_login, now
|
||||
self._login_requests, client_ip,
|
||||
self._effective_limit(settings.rate_limit_login), now,
|
||||
)
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
@@ -111,7 +174,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# Check general 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:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -10,16 +10,18 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
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
|
||||
CSP = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' "
|
||||
"https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdn.plot.ly; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
"font-src 'self' https://fonts.gstatic.com; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"font-src 'self'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self'"
|
||||
"connect-src 'self'; "
|
||||
"worker-src 'self' blob:"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Authentication router - login, logout, profile."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
@@ -34,11 +34,16 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
request: Request,
|
||||
all_sessions: bool = False,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Logout - invalidate API key."""
|
||||
await logout_user(db, user)
|
||||
"""Logout. Deletes this session's API key only; ?all_sessions=true
|
||||
deletes every key of the user."""
|
||||
await logout_user(
|
||||
db, user, key=request.headers.get("X-API-Key"), all_sessions=all_sessions
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
|
||||
@@ -6,9 +6,15 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_maker
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,6 +26,92 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
ALLOWED_PDF_TYPE = "application/pdf"
|
||||
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
|
||||
|
||||
# Magic-byte signatures: the client-declared Content-Type is only what the
|
||||
# browser claimed and cannot be trusted (#36 point 2 — a file named "x.html"
|
||||
# declared image/png was stored and then served by extension as text/html,
|
||||
# which a browser executes). SVG is intentionally not recognised: it is not
|
||||
# in ALLOWED_TYPES and can itself carry script content.
|
||||
_MAGIC_SIGNATURES: list[tuple[bytes, str, str]] = [
|
||||
# (signature, mime type, canonical extension)
|
||||
(b"\x89PNG\r\n\x1a\n", "image/png", ".png"),
|
||||
(b"\xff\xd8\xff", "image/jpeg", ".jpg"),
|
||||
(b"GIF87a", "image/gif", ".gif"),
|
||||
(b"GIF89a", "image/gif", ".gif"),
|
||||
(b"%PDF-", "application/pdf", ".pdf"),
|
||||
]
|
||||
|
||||
# Read uploads in chunks so a declared-huge body is rejected as soon as it
|
||||
# crosses the limit, instead of being buffered into memory in full first.
|
||||
_UPLOAD_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
def detect_file_type(content: bytes) -> tuple[str, str] | None:
|
||||
"""Identify a file's real type from its magic bytes.
|
||||
|
||||
Returns (mime_type, canonical_extension), or None when the content does
|
||||
not match any type this system serves.
|
||||
"""
|
||||
for signature, mime, ext in _MAGIC_SIGNATURES:
|
||||
if content.startswith(signature):
|
||||
return mime, ext
|
||||
# WebP: "RIFF" + 4-byte little-endian size + "WEBP"; the size varies.
|
||||
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
|
||||
return "image/webp", ".webp"
|
||||
return None
|
||||
|
||||
# Top-level folders nothing may DELETE through this router: statistics/reports are
|
||||
# audit records, the logo is only ever replaced via POST /api/settings/logo.
|
||||
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/")
|
||||
|
||||
# GET under these needs a role that is meant to see production data, not just
|
||||
# any authenticated user (a Maker or MeasurementTec has no business reading
|
||||
# every production's statistics/report file by guessing its name).
|
||||
_RESTRICTED_GET_PREFIXES = ("statistics/", "reports/")
|
||||
|
||||
|
||||
def _can_read_restricted(user: User) -> bool:
|
||||
return user.is_admin or user.has_role("Metrologist") or user.has_role("Supervisor")
|
||||
|
||||
|
||||
async def _is_recipe_file_protected(db: AsyncSession, relative_path: str) -> bool:
|
||||
"""True when `relative_path` (uploads-relative, forward slashes) must not be
|
||||
deleted: it is a recipe's cover image, or a task/subtask file belonging to a
|
||||
version that is either not the current one (kept for history) or already has
|
||||
measurements against it (the audit trail needs it to make sense of them).
|
||||
|
||||
A path not referenced anywhere is safe to delete (orphaned upload).
|
||||
"""
|
||||
recipe_hit = (await db.execute(
|
||||
select(Recipe.id).where(Recipe.image_path == relative_path)
|
||||
)).scalar_one_or_none()
|
||||
if recipe_hit is not None:
|
||||
return True
|
||||
|
||||
version_id = (await db.execute(
|
||||
select(RecipeTask.version_id).where(RecipeTask.file_path == relative_path)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if version_id is None:
|
||||
version_id = (await db.execute(
|
||||
select(RecipeTask.version_id)
|
||||
.join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id)
|
||||
.where(RecipeSubtask.image_path == relative_path)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if version_id is None:
|
||||
return False
|
||||
|
||||
version = (await db.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.id == version_id)
|
||||
)).scalar_one_or_none()
|
||||
if version is None or not version.is_current:
|
||||
return True
|
||||
|
||||
has_measurements = (await db.execute(
|
||||
select(Measurement.id).where(Measurement.version_id == version_id).limit(1)
|
||||
)).scalar_one_or_none() is not None
|
||||
return has_measurements
|
||||
|
||||
|
||||
def validate_file_type(content_type: str) -> bool:
|
||||
"""Validate if file type is allowed."""
|
||||
@@ -119,25 +211,36 @@ async def upload_file(
|
||||
"""Upload an image or PDF file.
|
||||
|
||||
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
|
||||
if not validate_file_type(file.content_type):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {file.content_type} not allowed. Must be image or PDF.",
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
# Read in chunks, stopping as soon as the size limit is crossed instead
|
||||
# of buffering an oversized body into memory first.
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
buffer = bytearray()
|
||||
while True:
|
||||
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
)
|
||||
content = bytes(buffer)
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file size
|
||||
if not validate_file_size(file_size):
|
||||
# Validate file type from the actual bytes, not file.content_type
|
||||
detected = detect_file_type(content)
|
||||
if detected is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=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
|
||||
if recipe_id is not None and version_id is not None:
|
||||
@@ -147,18 +250,17 @@ async def upload_file(
|
||||
|
||||
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 "")
|
||||
base_name = Path(safe_filename).stem or "file"
|
||||
safe_filename = f"{base_name}{detected_ext}"
|
||||
|
||||
# Generate unique filename if file already exists
|
||||
file_path = storage_dir / safe_filename
|
||||
counter = 1
|
||||
while file_path.exists():
|
||||
name_parts = safe_filename.rsplit(".", 1)
|
||||
if len(name_parts) == 2:
|
||||
file_path = storage_dir / f"{name_parts[0]}_{counter}.{name_parts[1]}"
|
||||
else:
|
||||
file_path = storage_dir / f"{safe_filename}_{counter}"
|
||||
file_path = storage_dir / f"{base_name}_{counter}{detected_ext}"
|
||||
counter += 1
|
||||
|
||||
# Write file
|
||||
@@ -166,7 +268,7 @@ async def upload_file(
|
||||
|
||||
# Generate thumbnail for images
|
||||
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.mkdir(exist_ok=True)
|
||||
thumbnail_path = thumbnail_dir / file_path.name
|
||||
@@ -183,7 +285,7 @@ async def upload_file(
|
||||
return {
|
||||
"file_path": str(relative_path).replace("\\", "/"),
|
||||
"thumbnail_path": str(thumbnail_relative).replace("\\", "/") if thumbnail_relative else None,
|
||||
"file_type": file.content_type,
|
||||
"file_type": detected_mime,
|
||||
"file_size": file_size,
|
||||
}
|
||||
|
||||
@@ -195,8 +297,14 @@ async def get_file(
|
||||
):
|
||||
"""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)
|
||||
return FileResponse(full_path)
|
||||
|
||||
@@ -205,13 +313,29 @@ async def get_file(
|
||||
async def delete_file(
|
||||
file_path: str,
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a file from the uploads directory.
|
||||
|
||||
Also deletes associated thumbnail if it exists.
|
||||
Also deletes associated thumbnail if it exists. statistics/, reports/ and
|
||||
logos/ can never be deleted here (403); a recipe file still referenced by
|
||||
its current version or by a version with measurements is kept (409).
|
||||
"""
|
||||
if file_path.startswith(_UNDELETABLE_PREFIXES):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="This folder cannot be deleted through the files API.",
|
||||
)
|
||||
|
||||
full_path = resolve_upload_path(file_path)
|
||||
|
||||
relative_path = str(full_path.relative_to(settings.upload_path)).replace("\\", "/")
|
||||
if await _is_recipe_file_protected(db, relative_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="File is referenced by a current or measured recipe version.",
|
||||
)
|
||||
|
||||
# Delete thumbnail if it exists
|
||||
if full_path.parent.name != "thumbnails":
|
||||
thumbnail_path = full_path.parent / "thumbnails" / full_path.name
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import csv
|
||||
import io
|
||||
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.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.user import User
|
||||
from src.backend.models.api.measurement import (
|
||||
CurrentCycleResponse,
|
||||
MeasurementAuthorisation,
|
||||
MeasurementBatchCreate,
|
||||
MeasurementCreate,
|
||||
MeasurementListResponse,
|
||||
MeasurementResponse,
|
||||
TaskProgressListResponse,
|
||||
)
|
||||
from src.backend.services import auth_service, measurement_service
|
||||
from src.backend.services.measurement_service import save_measurement
|
||||
from src.backend.services.production_export_service import neutralise_csv_cell
|
||||
|
||||
router = APIRouter(prefix="/api/measurements", tags=["measurements"])
|
||||
|
||||
@@ -47,6 +54,7 @@ async def create_measurement(
|
||||
serial_number=data.serial_number,
|
||||
input_method=data.input_method,
|
||||
input_duration_ms=data.input_duration_ms,
|
||||
production_run_id=data.production_run_id,
|
||||
)
|
||||
return MeasurementResponse.model_validate(measurement)
|
||||
except ValueError as e:
|
||||
@@ -56,6 +64,96 @@ 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,
|
||||
)
|
||||
measurement = await measurement_service.authorise_measurement(
|
||||
db, measurement, supervisor,
|
||||
)
|
||||
return MeasurementResponse.model_validate(measurement)
|
||||
|
||||
|
||||
@router.post("/batch", response_model=list[MeasurementResponse])
|
||||
async def create_measurement_batch(
|
||||
data: MeasurementBatchCreate,
|
||||
@@ -76,6 +174,7 @@ async def create_measurement_batch(
|
||||
serial_number=measurement_data.serial_number,
|
||||
input_method=measurement_data.input_method,
|
||||
input_duration_ms=measurement_data.input_duration_ms,
|
||||
production_run_id=measurement_data.production_run_id,
|
||||
)
|
||||
measurements.append(measurement)
|
||||
return [MeasurementResponse.model_validate(m) for m in measurements]
|
||||
@@ -96,6 +195,7 @@ def _build_measurement_filters(
|
||||
date_from: datetime | None,
|
||||
date_to: datetime | None,
|
||||
pass_fail: str | None,
|
||||
production_run_id: int | None = None,
|
||||
) -> list:
|
||||
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
||||
filters = []
|
||||
@@ -121,6 +221,8 @@ def _build_measurement_filters(
|
||||
filters.append(Measurement.measured_at <= date_to)
|
||||
if pass_fail is not None:
|
||||
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
|
||||
|
||||
|
||||
@@ -132,9 +234,10 @@ async def get_measurements(
|
||||
measured_by: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
production_run_id: int | None = Query(None, gt=0),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=500),
|
||||
user: User = Depends(get_current_user),
|
||||
@@ -150,7 +253,7 @@ async def get_measurements(
|
||||
"""
|
||||
filters = _build_measurement_filters(
|
||||
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
|
||||
@@ -189,15 +292,18 @@ async def export_measurements_csv(
|
||||
measured_by: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Export measurements to CSV with configurable delimiter and decimal separator."""
|
||||
# Check user has MeasurementTec or Metrologist role
|
||||
if not (user.has_role("MeasurementTec") or user.has_role("Metrologist")):
|
||||
# Check user has MeasurementTec or Metrologist role, same as require_role: an
|
||||
# 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(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="MeasurementTec or Metrologist role required",
|
||||
@@ -265,10 +371,10 @@ async def export_measurements_csv(
|
||||
value_str,
|
||||
m.pass_fail,
|
||||
deviation_str,
|
||||
m.lot_number or "",
|
||||
m.serial_number or "",
|
||||
neutralise_csv_cell(m.lot_number or ""),
|
||||
neutralise_csv_cell(m.serial_number or ""),
|
||||
m.input_method,
|
||||
m.measured_at.isoformat(),
|
||||
iso_utc(m.measured_at),
|
||||
m.synced_to_csv,
|
||||
])
|
||||
|
||||
@@ -278,6 +384,6 @@ async def export_measurements_csv(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
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,200 @@
|
||||
"""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",
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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.api.recipe import (
|
||||
RecipeCreate,
|
||||
RecipeDuplicate,
|
||||
RecipeListResponse,
|
||||
RecipeResponse,
|
||||
RecipeUpdate,
|
||||
@@ -52,6 +53,18 @@ async def create_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)
|
||||
async def list_recipes(
|
||||
page: int = Query(1, ge=1),
|
||||
@@ -189,6 +202,11 @@ async def parse_technical_sheet(
|
||||
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except ai_service.AIResponseError as e:
|
||||
# The AI model's own response was malformed (bad JSON, missing
|
||||
# fields): that is the upstream service's fault, not ours (400).
|
||||
logging.getLogger(__name__).error("AI response malformed: %s", e)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Risposta del servizio AI non valida")
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error("AI parsing error: %s", e)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""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.responses import Response
|
||||
@@ -18,11 +18,12 @@ async def download_spc_report(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
@@ -32,6 +33,7 @@ async def download_spc_report(
|
||||
db, recipe_id, subtask_id,
|
||||
version_id, date_from, date_to,
|
||||
operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
@@ -52,11 +54,12 @@ async def download_measurement_report(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int | None = Query(None),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
@@ -66,6 +69,7 @@ async def download_measurement_report(
|
||||
db, recipe_id, subtask_id,
|
||||
version_id, date_from, date_to,
|
||||
operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""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 sqlalchemy import select
|
||||
@@ -8,11 +9,88 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import get_current_user, require_admin_user
|
||||
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, detect_file_type
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
# Domains for the settings keys the backend actually reads. Values outside these
|
||||
# would only fail later (csv.writer TypeError on close_run, etc) — reject up front.
|
||||
_VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
|
||||
_VALID_CSV_DECIMAL_SEPARATORS = {",", "."}
|
||||
|
||||
|
||||
def _validate_string_list(key: str, value: str) -> None:
|
||||
"""#49: array JSON di stringhe non vuote, max 50 voci (vuoto = nessuna lista)."""
|
||||
try:
|
||||
items = json.loads(value)
|
||||
except ValueError:
|
||||
items = None
|
||||
if (
|
||||
not isinstance(items, list)
|
||||
or len(items) > 50
|
||||
or not all(isinstance(i, str) and i.strip() for i in items)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{key} deve essere vuoto o un array JSON di al massimo 50 stringhe non vuote",
|
||||
)
|
||||
|
||||
|
||||
def _validate_setting_value(key: str, value: str) -> None:
|
||||
"""Raise 422 for a value that would break the code reading this key.
|
||||
|
||||
Unrecognised keys pass through unvalidated: the settings store is generic.
|
||||
"""
|
||||
if key == "csv_delimiter":
|
||||
if value not in _VALID_CSV_DELIMITERS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="csv_delimiter deve essere un carattere singolo fra ',' ';' TAB '|'",
|
||||
)
|
||||
elif key == "csv_decimal_separator":
|
||||
if value not in _VALID_CSV_DECIMAL_SEPARATORS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="csv_decimal_separator deve essere ',' o '.'",
|
||||
)
|
||||
elif key == "auto_logout_minutes":
|
||||
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 480)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="auto_logout_minutes deve essere vuoto (disabilitato) o un intero fra 1 e 480",
|
||||
)
|
||||
elif key == "default_measurement_interval_minutes":
|
||||
# #47: vuoto = nessun default
|
||||
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 1440)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="default_measurement_interval_minutes deve essere vuoto o un intero fra 1 e 1440",
|
||||
)
|
||||
elif key in ("general_tolerance_plus", "general_tolerance_minus"):
|
||||
# #48: decimale >= 0 con punto; vuoto = nessun default
|
||||
if value != "":
|
||||
try:
|
||||
number = Decimal(value)
|
||||
ok = number.is_finite() and number >= 0
|
||||
except InvalidOperation:
|
||||
ok = False
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{key} deve essere vuoto o un numero decimale maggiore o uguale a 0 (punto decimale)",
|
||||
)
|
||||
elif key in ("list_measurement_types", "list_units"):
|
||||
if value != "":
|
||||
_validate_string_list(key, value)
|
||||
elif key in ("company_name", "company_logo_path"):
|
||||
if not value.strip() or len(value) > 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"{key} deve essere una stringa non vuota di massimo 200 caratteri",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def get_settings(
|
||||
@@ -49,6 +127,8 @@ async def update_settings(
|
||||
updated_keys = []
|
||||
|
||||
for key, value in settings_data.items():
|
||||
_validate_setting_value(key, value)
|
||||
|
||||
# Check if setting exists
|
||||
result = await db.execute(
|
||||
select(SystemSetting).where(SystemSetting.setting_key == key)
|
||||
@@ -88,35 +168,45 @@ async def upload_company_logo(
|
||||
"""Upload company logo.
|
||||
|
||||
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)
|
||||
allowed_types = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
||||
if file.content_type not in allowed_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {file.content_type} not allowed. Must be an image.",
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
# Read in chunks, stopping as soon as the size limit is crossed instead
|
||||
# of buffering an oversized body into memory first.
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
buffer = bytearray()
|
||||
chunk_size = 1024 * 1024
|
||||
while True:
|
||||
chunk = await file.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
)
|
||||
content = bytes(buffer)
|
||||
file_size = len(content)
|
||||
|
||||
# Validate file size
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
if file_size > max_bytes:
|
||||
# Validate file type from the actual bytes (SVG never matches: refused)
|
||||
detected = detect_file_type(content)
|
||||
if detected is None or detected[0] not in ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=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
|
||||
logos_dir = settings.upload_path / "logos"
|
||||
logos_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Use a fixed filename for the company logo
|
||||
file_extension = Path(file.filename).suffix
|
||||
logo_filename = f"company_logo{file_extension}"
|
||||
# Fixed filename for the company logo, extension forced to what was
|
||||
# actually detected (not what the client claimed or the filename said).
|
||||
logo_filename = f"company_logo{detected_ext}"
|
||||
logo_path = logos_dir / logo_filename
|
||||
|
||||
# Write file
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
|
||||
NOT protected by API Key auth (standalone access).
|
||||
"""
|
||||
import hmac
|
||||
import random
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from src.backend.utils.tempo import iso_utc, utcnow
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -22,15 +25,29 @@ from src.backend.models.orm import (
|
||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||
)
|
||||
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
|
||||
|
||||
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))
|
||||
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -87,13 +104,57 @@ def _check_setup_enabled() -> None:
|
||||
def _check_password(password: str) -> None:
|
||||
"""Verify the provided password matches the setup password."""
|
||||
_check_setup_enabled()
|
||||
if password != settings.setup_password:
|
||||
if not hmac.compare_digest(password, settings.setup_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
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(
|
||||
subtask: RecipeSubtask,
|
||||
version_id: int,
|
||||
@@ -220,14 +281,24 @@ async def setup_page(request: Request):
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def setup_status():
|
||||
"""Return database table status (no password required, but setup must be enabled)."""
|
||||
async def setup_status(password: str | None = None):
|
||||
"""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()
|
||||
initialised = await _admin_exists()
|
||||
if not _password_matches(password):
|
||||
return {"initialised": initialised}
|
||||
|
||||
async with engine.connect() as conn:
|
||||
table_names: list[str] = await conn.run_sync(
|
||||
lambda sync_conn: sa_inspect(sync_conn).get_table_names()
|
||||
)
|
||||
return {
|
||||
"initialised": initialised,
|
||||
"tables_exist": len(table_names) > 0,
|
||||
"table_count": len(table_names),
|
||||
"tables": table_names,
|
||||
@@ -238,8 +309,10 @@ async def setup_status():
|
||||
async def init_db(body: PasswordBody):
|
||||
"""Create all database tables."""
|
||||
_check_password(body.password)
|
||||
await _require_not_initialised()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
_stamp_head()
|
||||
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."""
|
||||
_check_password(body.password)
|
||||
|
||||
api_key = secrets.token_hex(32)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
user = User(
|
||||
@@ -259,9 +330,10 @@ async def create_admin(body: CreateAdminBody):
|
||||
display_name=body.admin_display_name,
|
||||
roles=["Maker", "MeasurementTec", "Metrologist"],
|
||||
is_admin=True,
|
||||
api_key=api_key,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
api_key = await issue_api_key(session, user)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
@@ -274,6 +346,7 @@ async def create_admin(body: CreateAdminBody):
|
||||
async def seed_demo_data(body: PasswordBody):
|
||||
"""Seed database with demo users and a sample recipe."""
|
||||
_check_password(body.password)
|
||||
await _require_not_initialised()
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
@@ -330,7 +403,6 @@ async def seed_demo_data(body: PasswordBody):
|
||||
email=u["email"],
|
||||
roles=u["roles"],
|
||||
is_admin=u["is_admin"],
|
||||
api_key=secrets.token_hex(32),
|
||||
)
|
||||
session.add(user)
|
||||
created_users.append(user)
|
||||
@@ -484,7 +556,7 @@ async def seed_demo_data(body: PasswordBody):
|
||||
tec_user = created_users[2] # tec1
|
||||
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)]
|
||||
base_date = datetime.now()
|
||||
base_date = utcnow()
|
||||
|
||||
all_subtasks = subtasks_1 + subtasks_2
|
||||
measurements_created = 0
|
||||
@@ -532,8 +604,8 @@ async def list_users(body: PasswordBody):
|
||||
"roles": u.roles or [],
|
||||
"is_admin": u.is_admin,
|
||||
"active": u.active,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
"last_login": u.last_login.isoformat() if u.last_login else None,
|
||||
"created_at": iso_utc(u.created_at) or None,
|
||||
"last_login": iso_utc(u.last_login) or None,
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
@@ -544,6 +616,7 @@ async def list_users(body: PasswordBody):
|
||||
async def manage_user(body: ManageUserBody):
|
||||
"""Create or update a user. user_id=null creates new, user_id=int updates."""
|
||||
_check_password(body.password)
|
||||
await _require_not_initialised()
|
||||
|
||||
invalid = set(body.roles) - VALID_ROLES
|
||||
if invalid:
|
||||
@@ -577,7 +650,6 @@ async def manage_user(body: ManageUserBody):
|
||||
email=body.email,
|
||||
roles=body.roles,
|
||||
is_admin=body.is_admin,
|
||||
api_key=secrets.token_hex(32),
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
@@ -618,6 +690,7 @@ async def manage_user(body: ManageUserBody):
|
||||
async def change_user_password(body: ChangeUserPasswordBody):
|
||||
"""Change a user's password and invalidate their API key."""
|
||||
_check_password(body.password)
|
||||
await _require_not_initialised()
|
||||
|
||||
async with async_session_factory() as session:
|
||||
async with session.begin():
|
||||
@@ -631,7 +704,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
|
||||
detail=f"User ID {body.user_id} not found",
|
||||
)
|
||||
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}'"}
|
||||
|
||||
@@ -654,7 +727,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
|
||||
)
|
||||
user.active = 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"
|
||||
|
||||
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):
|
||||
"""Drop all tables and recreate them. Requires confirm=true."""
|
||||
_check_password(body.password)
|
||||
await _require_not_initialised()
|
||||
|
||||
if not body.confirm:
|
||||
raise HTTPException(
|
||||
@@ -674,5 +748,6 @@ async def reset_db(body: ResetDbBody):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
_stamp_head()
|
||||
|
||||
return {"status": "ok", "message": "Database reset successfully"}
|
||||
|
||||
@@ -11,6 +11,8 @@ from src.backend.models.api.station import (
|
||||
StationResponse,
|
||||
StationRecipeAssignmentCreate,
|
||||
StationRecipeAssignmentResponse,
|
||||
StationResetResponse,
|
||||
StationWithRecipesResponse,
|
||||
RecipeSummary,
|
||||
)
|
||||
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.get("", response_model=list[StationResponse])
|
||||
@router.get("", response_model=list[StationWithRecipesResponse])
|
||||
async def list_stations(
|
||||
active_only: bool = False,
|
||||
admin: User = Depends(require_admin_user),
|
||||
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)
|
||||
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)
|
||||
@@ -128,6 +151,22 @@ async def assign_recipe_to_station(
|
||||
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(
|
||||
"/{station_id}/recipes/{recipe_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Statistics router - SPC endpoints for Metrologist dashboard."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcNaiveInput
|
||||
|
||||
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 src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_metrologist
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
@@ -37,6 +39,7 @@ async def _query_measurements(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58: solo le misure di una sessione
|
||||
) -> list[Measurement]:
|
||||
"""Query measurements with common filters.
|
||||
|
||||
@@ -66,6 +69,9 @@ async def _query_measurements(
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
|
||||
query = (
|
||||
select(Measurement)
|
||||
.where(and_(*filters) if filters else True)
|
||||
@@ -100,11 +106,12 @@ async def get_summary(
|
||||
recipe_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
subtask_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SummaryData:
|
||||
@@ -112,6 +119,7 @@ async def get_summary(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
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]
|
||||
return compute_summary(pass_fail_values)
|
||||
@@ -122,11 +130,12 @@ async def get_capability(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CapabilityData:
|
||||
@@ -134,6 +143,7 @@ async def get_capability(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
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]
|
||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||
@@ -145,11 +155,12 @@ async def get_control_chart(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_number: str | None = Query(None),
|
||||
serial_number: str | None = Query(None),
|
||||
production_run_id: int | None = Query(None), # #58
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ControlChartData:
|
||||
@@ -157,6 +168,7 @@ async def get_control_chart(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
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]
|
||||
timestamps = [m.measured_at for m in measurements]
|
||||
@@ -172,11 +184,12 @@ async def get_histogram(
|
||||
recipe_id: int = Query(...),
|
||||
subtask_id: int = Query(...),
|
||||
version_id: int | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
date_from: UtcNaiveInput = Query(None),
|
||||
date_to: UtcNaiveInput = Query(None),
|
||||
operator_id: int | None = Query(None),
|
||||
lot_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),
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -185,6 +198,7 @@ async def get_histogram(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
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]
|
||||
return compute_histogram(values, n_bins)
|
||||
@@ -234,3 +248,49 @@ async def get_recipe_subtasks(
|
||||
})
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
@@ -17,8 +17,10 @@ from src.backend.models.api.task import (
|
||||
TaskReorderRequest,
|
||||
TaskResponse,
|
||||
TaskUpdate,
|
||||
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"])
|
||||
|
||||
@@ -99,6 +101,64 @@ async def _ensure_version_is_current(db: AsyncSession, version_id: int) -> Recip
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -172,12 +232,14 @@ async def create_task(
|
||||
new_task = RecipeTask(
|
||||
version_id=new_version.id,
|
||||
order_index=max_order + 1,
|
||||
task_type=data.task_type,
|
||||
title=data.title,
|
||||
directive=data.directive,
|
||||
description=data.description,
|
||||
file_path=data.file_path,
|
||||
file_type=data.file_type,
|
||||
annotations_json=data.annotations_json,
|
||||
vision_json=data.vision_json,
|
||||
)
|
||||
db.add(new_task)
|
||||
await db.flush()
|
||||
@@ -196,6 +258,7 @@ async def create_task(
|
||||
ltl=sub_data.ltl,
|
||||
unit=sub_data.unit,
|
||||
image_path=sub_data.image_path,
|
||||
vision_output=sub_data.vision_output,
|
||||
)
|
||||
db.add(sub)
|
||||
|
||||
@@ -261,10 +324,16 @@ async def reorder_tasks(
|
||||
async def update_task(
|
||||
task_id: int,
|
||||
data: TaskUpdate,
|
||||
_user: User = Depends(require_maker),
|
||||
user: User = Depends(require_maker),
|
||||
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)
|
||||
await _ensure_version_is_current(db, task.version_id)
|
||||
|
||||
@@ -272,6 +341,8 @@ async def update_task(
|
||||
if not update_data:
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
task = await _task_for_write(db, task, user)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(task, field, value)
|
||||
|
||||
@@ -283,18 +354,37 @@ async def update_task(
|
||||
@router.delete("/api/tasks/{task_id}", status_code=204)
|
||||
async def delete_task(
|
||||
task_id: int,
|
||||
_user: User = Depends(require_maker),
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""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)
|
||||
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.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 +399,19 @@ async def delete_task(
|
||||
async def create_subtask(
|
||||
task_id: int,
|
||||
data: SubtaskCreate,
|
||||
_user: User = Depends(require_maker),
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""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)
|
||||
await _ensure_version_is_current(db, task.version_id)
|
||||
|
||||
task = await _task_for_write(db, task, user)
|
||||
|
||||
# Check marker_number uniqueness within the task
|
||||
existing_markers = {s.marker_number for s in task.subtasks}
|
||||
if data.marker_number in existing_markers:
|
||||
@@ -328,7 +421,7 @@ async def create_subtask(
|
||||
)
|
||||
|
||||
subtask = RecipeSubtask(
|
||||
task_id=task_id,
|
||||
task_id=task.id,
|
||||
marker_number=data.marker_number,
|
||||
description=data.description,
|
||||
measurement_type=data.measurement_type,
|
||||
@@ -339,6 +432,7 @@ async def create_subtask(
|
||||
ltl=data.ltl,
|
||||
unit=data.unit,
|
||||
image_path=data.image_path,
|
||||
vision_output=data.vision_output,
|
||||
)
|
||||
db.add(subtask)
|
||||
await db.flush()
|
||||
@@ -350,12 +444,13 @@ async def create_subtask(
|
||||
async def update_subtask(
|
||||
subtask_id: int,
|
||||
data: SubtaskUpdate,
|
||||
_user: User = Depends(require_maker),
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""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)
|
||||
|
||||
@@ -367,6 +462,24 @@ async def update_subtask(
|
||||
if not update_data:
|
||||
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():
|
||||
setattr(subtask, field, value)
|
||||
|
||||
@@ -378,12 +491,13 @@ async def update_subtask(
|
||||
@router.delete("/api/subtasks/{subtask_id}", status_code=204)
|
||||
async def delete_subtask(
|
||||
subtask_id: int,
|
||||
_user: User = Depends(require_maker),
|
||||
user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""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)
|
||||
|
||||
@@ -391,5 +505,7 @@ async def delete_subtask(
|
||||
task = await _get_task_or_404(db, subtask.task_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.flush()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Users router - CRUD operations (admin only)."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_admin_user
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.user import 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
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
@@ -78,6 +78,21 @@ async def update_user(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
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
|
||||
if "roles" in update_data:
|
||||
for role in update_data["roles"]:
|
||||
@@ -86,6 +101,27 @@ async def update_user(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
|
||||
)
|
||||
|
||||
# Losing the last admin locks everyone out of user management; losing
|
||||
# your own admin flag while acting as admin is the same mistake aimed
|
||||
# at yourself. Neither is reversible without direct DB access.
|
||||
if update_data.get("is_admin") is False and user.is_admin:
|
||||
if user.id == admin.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot remove your own admin privilege",
|
||||
)
|
||||
other_admins = await db.execute(
|
||||
select(func.count()).select_from(User).where(
|
||||
User.is_admin == True, User.id != user.id # noqa: E712
|
||||
)
|
||||
)
|
||||
if other_admins.scalar_one() == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot remove admin privilege from the last admin",
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
await db.flush()
|
||||
@@ -100,12 +136,17 @@ async def change_user_password(
|
||||
admin: User = Depends(require_admin_user),
|
||||
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))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user.password_hash = hash_password(data.password)
|
||||
await revoke_all_keys(db, user.id)
|
||||
await db.flush()
|
||||
return {"message": f"Password changed for user {user.username}"}
|
||||
|
||||
@@ -127,7 +168,7 @@ async def deactivate_user(
|
||||
detail="Cannot deactivate yourself",
|
||||
)
|
||||
user.active = False
|
||||
user.api_key = None
|
||||
await revoke_all_keys(db, user.id)
|
||||
await db.flush()
|
||||
|
||||
|
||||
@@ -142,5 +183,5 @@ async def regenerate_user_key(
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
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}"}
|
||||
|
||||
@@ -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."""
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# Committed defaults. Fine for dev/test (SQLite in-memory, nothing exposed); a
|
||||
# real deployment that still has these is running on secrets anyone can read
|
||||
# in this file's git history.
|
||||
_DEFAULT_SECRET_KEY = "change-this-to-a-random-secret-key"
|
||||
_DEFAULT_DB_PASSWORD = "change_me_in_production"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Deployment environment. "dev" and "test" skip the production-only guard
|
||||
# below; anything else (e.g. "production") is treated as a real
|
||||
# deployment. Local dev and the test suite must keep starting with no
|
||||
# .env at all, which is why this defaults to "dev" rather than requiring
|
||||
# the variable.
|
||||
app_env: str = "dev"
|
||||
|
||||
# Database
|
||||
db_host: str = "localhost"
|
||||
db_port: int = 3306
|
||||
db_name: str = "tiemeasureflow"
|
||||
db_user: str = "tmflow"
|
||||
db_password: str = "change_me_in_production"
|
||||
db_password: str = _DEFAULT_DB_PASSWORD
|
||||
|
||||
# Presentation time zone (DB is UTC, #42)
|
||||
display_timezone: str = "Europe/Rome"
|
||||
|
||||
# Server
|
||||
server_host: str = "0.0.0.0"
|
||||
server_port: int = 8000
|
||||
server_secret_key: str = "change-this-to-a-random-secret-key"
|
||||
server_secret_key: str = _DEFAULT_SECRET_KEY
|
||||
server_cors_origins: str = "http://localhost:5000"
|
||||
# Worker processes (e.g. `uvicorn --workers N`). Each rate-limit bucket lives
|
||||
# in process memory, so this is how RateLimitMiddleware divides the
|
||||
# configured limits to keep the cluster-wide effective limit close to what
|
||||
# was configured instead of multiplying it by N.
|
||||
web_concurrency: int = 1
|
||||
|
||||
# API key session lifetime (hours); renewed on use (env API_KEY_TTL_HOURS).
|
||||
api_key_ttl_hours: int = 24
|
||||
# How many reverse proxies between the client and this API are trusted to
|
||||
# have appended their own hop to X-Forwarded-For (Nginx, in the default
|
||||
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
|
||||
# parsing entirely (only request.client.host is trusted).
|
||||
trusted_proxy_count: int = 1
|
||||
|
||||
# File Storage
|
||||
upload_dir: str = "uploads"
|
||||
@@ -33,11 +64,18 @@ class Settings(BaseSettings):
|
||||
|
||||
# Setup page (empty = disabled)
|
||||
setup_password: str | None = None
|
||||
# By default, once an admin user exists, setup's privileged endpoints
|
||||
# (reset-db, seed, manage-user, change-user-password, init-db) refuse to
|
||||
# run again. Set true for scripted re-provisioning of a demo/test deploy.
|
||||
setup_allow_after_init: bool = False
|
||||
|
||||
# AI / OpenRouter (for technical sheet parsing)
|
||||
openrouter_api_key: str | None = None
|
||||
openrouter_model: str = "anthropic/claude-sonnet-4"
|
||||
|
||||
# Vision worker (internal network only, reachable as `vision` in Compose)
|
||||
vision_worker_url: str = "http://vision:8100"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Async MySQL connection string."""
|
||||
@@ -69,5 +107,27 @@ class Settings(BaseSettings):
|
||||
"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()
|
||||
|
||||
@@ -21,6 +21,8 @@ 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.setup import router as setup_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
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -73,6 +75,8 @@ app.include_router(statistics_router)
|
||||
app.include_router(reports_router)
|
||||
app.include_router(setup_router)
|
||||
app.include_router(stations_router)
|
||||
app.include_router(production_router)
|
||||
app.include_router(vision_router)
|
||||
|
||||
|
||||
@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.access_log import AccessLog # 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
|
||||
|
||||
|
||||
@@ -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: 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
|
||||
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')
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for Measurement operations."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -9,11 +9,25 @@ class MeasurementCreate(BaseModel):
|
||||
"""Schema for creating a single measurement."""
|
||||
subtask_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
|
||||
lot_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_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):
|
||||
@@ -36,7 +50,12 @@ class MeasurementResponse(BaseModel):
|
||||
serial_number: Optional[str] = None
|
||||
input_method: str
|
||||
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
|
||||
|
||||
|
||||
@@ -49,6 +68,44 @@ class MeasurementListResponse(BaseModel):
|
||||
pages: int
|
||||
|
||||
|
||||
class MeasurementAuthorisation(BaseModel):
|
||||
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
||||
|
||||
supervisor_username: str = Field(..., min_length=1)
|
||||
supervisor_password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
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):
|
||||
"""Schema for measurement query filters."""
|
||||
recipe_id: Optional[int] = None
|
||||
@@ -57,8 +114,8 @@ class MeasurementQuery(BaseModel):
|
||||
measured_by: Optional[int] = None
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
date_from: UtcNaiveInput = None
|
||||
date_to: UtcNaiveInput = None
|
||||
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
||||
page: int = Field(1, ge=1)
|
||||
per_page: int = Field(50, ge=1, le=500)
|
||||
|
||||
@@ -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,89 @@
|
||||
"""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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||
events: list[ProductionEventResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SupervisorAction(BaseModel):
|
||||
"""Credentials of the supervisor authorising a line stop or a close."""
|
||||
|
||||
supervisor_username: str = Field(..., min_length=1)
|
||||
supervisor_password: str = Field(..., min_length=1)
|
||||
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."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -15,18 +15,32 @@ class RecipeCreate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
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
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
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):
|
||||
"""Schema for updating a recipe (creates new version)."""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
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
|
||||
# Task-level fields: saved to the first task of the new version
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
@@ -43,7 +57,7 @@ class RecipeVersionResponse(BaseModel):
|
||||
version_number: int
|
||||
is_current: bool
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
created_at: UtcDateTime
|
||||
change_notes: Optional[str] = None
|
||||
tasks: list["TaskResponse"] = []
|
||||
|
||||
@@ -58,8 +72,13 @@ class RecipeResponse(BaseModel):
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = 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_at: datetime
|
||||
created_at: UtcDateTime
|
||||
active: bool
|
||||
current_version: Optional[RecipeVersionResponse] = None
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -29,7 +29,7 @@ class StationResponse(BaseModel):
|
||||
notes: Optional[str]
|
||||
active: bool
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
created_at: UtcDateTime
|
||||
|
||||
|
||||
class StationRecipeAssignmentCreate(BaseModel):
|
||||
@@ -42,7 +42,7 @@ class StationRecipeAssignmentResponse(BaseModel):
|
||||
station_id: int
|
||||
recipe_id: int
|
||||
assigned_by: int
|
||||
assigned_at: datetime
|
||||
assigned_at: UtcDateTime
|
||||
|
||||
|
||||
class RecipeSummary(BaseModel):
|
||||
@@ -53,7 +53,18 @@ class RecipeSummary(BaseModel):
|
||||
active: bool
|
||||
image_path: 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):
|
||||
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."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
|
||||
recipe_id: int
|
||||
version_id: Optional[int] = None
|
||||
subtask_id: Optional[int] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
date_from: UtcNaiveInput = None
|
||||
date_to: UtcNaiveInput = None
|
||||
operator_id: Optional[int] = None
|
||||
lot_number: Optional[str] = None
|
||||
serial_number: Optional[str] = None
|
||||
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
|
||||
class ControlChartData(BaseModel):
|
||||
"""Data for X-bar/R control chart."""
|
||||
values: list[float]
|
||||
timestamps: list[datetime]
|
||||
timestamps: list[UtcDateTime]
|
||||
mean: float
|
||||
ucl: float # Upper Control Limit
|
||||
lcl: float # Lower Control Limit
|
||||
@@ -50,7 +50,8 @@ class CapabilityData(BaseModel):
|
||||
pp: Optional[float] = None
|
||||
ppk: Optional[float] = None
|
||||
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
|
||||
utl: Optional[float] = None
|
||||
ltl: Optional[float] = None
|
||||
@@ -59,7 +60,7 @@ class CapabilityData(BaseModel):
|
||||
|
||||
class TrendData(BaseModel):
|
||||
"""Temporal trend data for capability indices."""
|
||||
dates: list[datetime]
|
||||
dates: list[UtcDateTime]
|
||||
cpk_values: list[Optional[float]]
|
||||
ppk_values: list[Optional[float]]
|
||||
n_per_period: list[int]
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||
from typing import Any, Optional
|
||||
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 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}"
|
||||
)
|
||||
|
||||
|
||||
class SubtaskCreate(BaseModel):
|
||||
@@ -16,6 +46,12 @@ class SubtaskCreate(BaseModel):
|
||||
ltl: Optional[float] = None
|
||||
unit: str = Field("mm", max_length=20)
|
||||
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):
|
||||
@@ -30,6 +66,7 @@ class SubtaskUpdate(BaseModel):
|
||||
ltl: Optional[float] = None
|
||||
unit: Optional[str] = Field(None, max_length=20)
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
vision_output: Optional[str] = None
|
||||
|
||||
|
||||
class SubtaskResponse(BaseModel):
|
||||
@@ -48,27 +85,32 @@ class SubtaskResponse(BaseModel):
|
||||
ltl: Optional[float] = None
|
||||
unit: str
|
||||
image_path: Optional[str] = None
|
||||
vision_output: Optional[str] = None
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
"""Schema for creating a task."""
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
task_type: TaskType = "note"
|
||||
directive: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
subtasks: list[SubtaskCreate] = []
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
"""Schema for updating a task."""
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
task_type: Optional[TaskType] = None
|
||||
directive: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
@@ -79,12 +121,14 @@ class TaskResponse(BaseModel):
|
||||
version_id: int
|
||||
recipe_id: Optional[int] = None
|
||||
order_index: int
|
||||
task_type: str
|
||||
title: str
|
||||
directive: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
file_path: Optional[str] = None
|
||||
file_type: Optional[str] = None
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
subtasks: list[SubtaskResponse] = []
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
@@ -48,8 +48,8 @@ class UserResponse(BaseModel):
|
||||
language_pref: str
|
||||
theme_pref: str
|
||||
active: bool
|
||||
created_at: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
created_at: UtcDateTime
|
||||
last_login: Optional[UtcDateTime] = None
|
||||
|
||||
|
||||
class UserPasswordChange(BaseModel):
|
||||
|
||||
@@ -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,14 +1,18 @@
|
||||
"""SQLAlchemy models for TieMeasureFlow."""
|
||||
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.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.access_log import AccessLog
|
||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||
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__ = [
|
||||
"User",
|
||||
"ApiKey",
|
||||
"Recipe",
|
||||
"RecipeVersion",
|
||||
"RecipeTask",
|
||||
@@ -19,4 +23,8 @@ __all__ = [
|
||||
"RecipeVersionAudit",
|
||||
"Station",
|
||||
"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: Mapped[str] = mapped_column(
|
||||
Enum("usb_caliper", "manual", name="input_method_enum"),
|
||||
Enum("usb_caliper", "manual", "camera", name="input_method_enum"),
|
||||
nullable=False,
|
||||
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)
|
||||
input_duration_ms: Mapped[Optional[int]] = mapped_column(
|
||||
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
|
||||
measured_at: Mapped[datetime] = mapped_column(
|
||||
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)
|
||||
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
|
||||
versions: Mapped[list["RecipeVersion"]] = relationship(
|
||||
back_populates="recipe", cascade="all, delete-orphan", lazy="selectin"
|
||||
|
||||
@@ -12,6 +12,16 @@ if TYPE_CHECKING:
|
||||
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.
|
||||
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
|
||||
|
||||
# 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")
|
||||
|
||||
|
||||
class RecipeTask(Base):
|
||||
__tablename__ = "recipe_tasks"
|
||||
|
||||
@@ -20,6 +30,16 @@ class RecipeTask(Base):
|
||||
Integer, ForeignKey("recipe_versions.id"), nullable=False
|
||||
)
|
||||
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)
|
||||
directive: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
@@ -28,6 +48,13 @@ class RecipeTask(Base):
|
||||
Enum("image", "pdf", name="file_type_enum"), 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)
|
||||
|
||||
# Relationships
|
||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||
@@ -70,6 +97,12 @@ class RecipeSubtask(Base):
|
||||
|
||||
unit: Mapped[str] = mapped_column(String(20), nullable=False, default="mm")
|
||||
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
|
||||
task: Mapped["RecipeTask"] = relationship(back_populates="subtasks")
|
||||
|
||||
@@ -32,7 +32,6 @@ class User(Base):
|
||||
|
||||
# Status
|
||||
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
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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)
|
||||
# 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."""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from io import BytesIO
|
||||
@@ -10,6 +11,17 @@ from src.backend.config import settings
|
||||
|
||||
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.
|
||||
Analizza il testo estratto da una scheda tecnica e identifica i blocchi operativi distinti.
|
||||
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]:
|
||||
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():
|
||||
return []
|
||||
|
||||
@@ -62,7 +76,10 @@ async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
||||
resp.raise_for_status()
|
||||
|
||||
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
|
||||
if content.startswith("```"):
|
||||
@@ -71,4 +88,10 @@ async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
||||
content = content[:-3]
|
||||
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."""
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from src.backend.utils.tempo import utcnow
|
||||
|
||||
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 src.backend.config import settings
|
||||
from src.backend.models.orm.api_key import ApiKey
|
||||
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
|
||||
|
||||
|
||||
# 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(
|
||||
db: AsyncSession, username: str, password: str
|
||||
) -> User | None:
|
||||
@@ -34,29 +46,100 @@ async def authenticate_user(
|
||||
select(User).where(User.username == username, User.active == True)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
if user is None:
|
||||
verify_password(password, _DUMMY_PASSWORD_HASH)
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
async def login_user(db: AsyncSession, user: User) -> str:
|
||||
"""Generate API key and update last_login for user."""
|
||||
api_key = generate_api_key()
|
||||
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> 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.
|
||||
"""
|
||||
supervisor = await authenticate_user(db, username, password)
|
||||
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(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(api_key=api_key, last_login=datetime.utcnow())
|
||||
delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
|
||||
)
|
||||
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()
|
||||
return api_key
|
||||
|
||||
|
||||
async def logout_user(db: AsyncSession, user: User) -> None:
|
||||
"""Invalidate user's API key."""
|
||||
await db.execute(
|
||||
update(User).where(User.id == user.id).values(api_key=None)
|
||||
)
|
||||
await db.flush()
|
||||
async def logout_user(
|
||||
db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
|
||||
) -> None:
|
||||
"""Log out. Deletes only the key of this session; all_sessions=True
|
||||
deletes every key of the user."""
|
||||
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(
|
||||
@@ -87,11 +170,7 @@ async def create_user(
|
||||
return user
|
||||
|
||||
|
||||
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
|
||||
"""Regenerate API key for a user."""
|
||||
new_key = generate_api_key()
|
||||
await db.execute(
|
||||
update(User).where(User.id == user_id).values(api_key=new_key)
|
||||
)
|
||||
await db.flush()
|
||||
return new_key
|
||||
async def regenerate_api_key(db: AsyncSession, user: User) -> str:
|
||||
"""Revoke every key of the user and issue a fresh one."""
|
||||
await revoke_all_keys(db, user.id)
|
||||
return await issue_api_key(db, user)
|
||||
|
||||
@@ -1,11 +1,53 @@
|
||||
"""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 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 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(
|
||||
@@ -19,7 +61,12 @@ def calculate_pass_fail(
|
||||
- If value is outside UTL/LTL -> 'fail'
|
||||
- If value is outside UWL/LWL but inside UTL/LTL -> 'warning'
|
||||
- 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
|
||||
if subtask.nominal is not None:
|
||||
deviation = float(Decimal(str(value)) - Decimal(str(subtask.nominal)))
|
||||
@@ -39,6 +86,270 @@ def calculate_pass_fail(
|
||||
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(
|
||||
db: AsyncSession,
|
||||
subtask_id: int,
|
||||
@@ -49,8 +360,21 @@ async def save_measurement(
|
||||
serial_number: str | None = None,
|
||||
input_method: str = "manual",
|
||||
input_duration_ms: int | None = None,
|
||||
production_run_id: int | None = None,
|
||||
vision_result_id: int | None = None,
|
||||
enforce_tolerance_gate: bool = True,
|
||||
) -> 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
|
||||
result = await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.id == subtask_id)
|
||||
@@ -59,6 +383,45 @@ async def save_measurement(
|
||||
if subtask is None:
|
||||
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)
|
||||
|
||||
measurement = Measurement(
|
||||
@@ -72,6 +435,8 @@ async def save_measurement(
|
||||
serial_number=serial_number,
|
||||
input_method=input_method,
|
||||
input_duration_ms=input_duration_ms,
|
||||
production_run_id=production_run_id,
|
||||
vision_result_id=vision_result_id,
|
||||
)
|
||||
db.add(measurement)
|
||||
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,481 @@
|
||||
"""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,
|
||||
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
||||
)
|
||||
.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.task import RecipeSubtask, RecipeTask
|
||||
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
|
||||
|
||||
|
||||
@@ -19,6 +20,51 @@ from src.backend.models.api.recipe import RecipeCreate, RecipeUpdate
|
||||
# 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:
|
||||
"""Return a recipe or raise 404."""
|
||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||
@@ -55,16 +101,48 @@ async def _copy_tasks_to_version(
|
||||
new_task = RecipeTask(
|
||||
version_id=target_version.id,
|
||||
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,
|
||||
directive=task.directive,
|
||||
description=task.description,
|
||||
file_path=task.file_path,
|
||||
file_type=task.file_type,
|
||||
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,
|
||||
)
|
||||
db.add(new_task)
|
||||
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:
|
||||
new_sub = RecipeSubtask(
|
||||
task_id=new_task.id,
|
||||
@@ -78,6 +156,7 @@ async def _copy_tasks_to_version(
|
||||
ltl=sub.ltl,
|
||||
unit=sub.unit,
|
||||
image_path=sub.image_path,
|
||||
vision_output=sub.vision_output,
|
||||
)
|
||||
db.add(new_sub)
|
||||
|
||||
@@ -135,6 +214,9 @@ async def create_recipe(
|
||||
description=data.description,
|
||||
image_path=data.image_path,
|
||||
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,
|
||||
)
|
||||
db.add(recipe)
|
||||
@@ -173,6 +255,78 @@ async def create_recipe(
|
||||
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(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
@@ -254,6 +408,7 @@ async def create_new_version(
|
||||
default_task = RecipeTask(
|
||||
version_id=new_version.id,
|
||||
order_index=0,
|
||||
task_type="drawing",
|
||||
title="Technical Drawing",
|
||||
file_path=data.file_path,
|
||||
file_type=data.file_type or (
|
||||
@@ -267,15 +422,7 @@ async def create_new_version(
|
||||
await db.flush()
|
||||
|
||||
# Apply header updates
|
||||
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
|
||||
update_fields = _header_fields(data)
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
@@ -364,16 +511,7 @@ async def update_current_version(
|
||||
data: RecipeUpdate,
|
||||
) -> RecipeVersion:
|
||||
"""Update recipe header in-place on the current version (no copy-on-write)."""
|
||||
# Apply header updates (name, description, image_path)
|
||||
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
|
||||
update_fields = _header_fields(data)
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
@@ -405,6 +543,7 @@ async def update_current_version(
|
||||
default_task = RecipeTask(
|
||||
version_id=current.id,
|
||||
order_index=0,
|
||||
task_type="drawing",
|
||||
title="Technical Drawing",
|
||||
file_path=data.file_path,
|
||||
file_type=data.file_type or (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
||||
import asyncio
|
||||
import base64
|
||||
from datetime import datetime
|
||||
from src.backend.utils.tempo import to_local, utcnow
|
||||
from pathlib import Path
|
||||
|
||||
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(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
@@ -41,6 +48,7 @@ async def _query_measurements(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> list[Measurement]:
|
||||
"""Query measurements with common filters (duplicated from statistics router)."""
|
||||
filters = []
|
||||
@@ -66,6 +74,9 @@ async def _query_measurements(
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
|
||||
query = (
|
||||
select(Measurement)
|
||||
.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")
|
||||
|
||||
|
||||
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:
|
||||
"""Generate histogram SVG using Plotly + Kaleido."""
|
||||
if not hist_data.bins:
|
||||
@@ -269,6 +285,7 @@ async def generate_spc_report(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> bytes:
|
||||
"""Generate SPC PDF report and return bytes.
|
||||
|
||||
@@ -278,6 +295,7 @@ async def generate_spc_report(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
|
||||
# 2. Compute SPC data
|
||||
@@ -300,9 +318,13 @@ async def generate_spc_report(
|
||||
)
|
||||
histogram = compute_histogram(values)
|
||||
|
||||
# 3. Generate SVG charts
|
||||
control_chart_svg = _generate_control_chart_svg(control_chart, subtask_info)
|
||||
histogram_svg = _generate_histogram_svg(histogram, subtask_info)
|
||||
# 3. Generate SVG charts (kaleido shells out and blocks: off the event loop)
|
||||
control_chart_svg = await asyncio.to_thread(
|
||||
_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
|
||||
company = await _get_company_info(db)
|
||||
@@ -313,13 +335,15 @@ async def generate_spc_report(
|
||||
if version_id:
|
||||
filters_desc.append(f"Version: {version_id}")
|
||||
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:
|
||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||
if lot_number:
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if 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
|
||||
template = _jinja_env.get_template("spc_report.html")
|
||||
@@ -332,12 +356,12 @@ async def generate_spc_report(
|
||||
control_chart_svg=control_chart_svg,
|
||||
histogram_svg=histogram_svg,
|
||||
filters_desc=filters_desc,
|
||||
generated_at=datetime.now(),
|
||||
generated_at=to_local(utcnow()),
|
||||
n_measurements=len(measurements),
|
||||
)
|
||||
|
||||
# 7. Convert HTML to PDF
|
||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||||
# 7. Convert HTML to PDF (WeasyPrint blocks: off the event loop)
|
||||
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
|
||||
return pdf_bytes
|
||||
|
||||
|
||||
@@ -351,12 +375,14 @@ async def generate_measurement_report(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58
|
||||
) -> bytes:
|
||||
"""Generate measurement table PDF report and return bytes."""
|
||||
# 1. Query measurements
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
|
||||
# 2. Get recipe info
|
||||
@@ -385,7 +411,7 @@ async def generate_measurement_report(
|
||||
"value": f"{float(m.value):.4f}",
|
||||
"unit": st_info.get("unit", "mm"),
|
||||
"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}"),
|
||||
"lot_number": m.lot_number or "—",
|
||||
"serial_number": m.serial_number or "—",
|
||||
@@ -404,13 +430,15 @@ async def generate_measurement_report(
|
||||
if version_id:
|
||||
filters_desc.append(f"Version: {version_id}")
|
||||
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:
|
||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
||||
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||
if lot_number:
|
||||
filters_desc.append(f"Lot: {lot_number}")
|
||||
if 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
|
||||
template = _jinja_env.get_template("measurement_report.html")
|
||||
@@ -420,10 +448,10 @@ async def generate_measurement_report(
|
||||
rows=rows,
|
||||
summary=summary,
|
||||
filters_desc=filters_desc,
|
||||
generated_at=datetime.now(),
|
||||
generated_at=to_local(utcnow()),
|
||||
n_measurements=len(measurements),
|
||||
)
|
||||
|
||||
# 8. Convert to PDF
|
||||
pdf_bytes = HTML(string=html_content).write_pdf()
|
||||
# 8. Convert to PDF (WeasyPrint blocks: off the event loop)
|
||||
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
|
||||
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(
|
||||
values: list[float],
|
||||
utl: float | None,
|
||||
@@ -63,12 +84,18 @@ def compute_capability(
|
||||
) -> CapabilityData:
|
||||
"""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)
|
||||
- Cpk = min((UTL - mean) / (3 * sigma), (mean - LTL) / (3 * sigma))
|
||||
- Pp/Ppk: same formulas using population std dev (same data, no subgrouping)
|
||||
- Cpk = min((UTL - mean) / (3 * sigma_within), (mean - LTL) / (3 * sigma_within))
|
||||
- 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:
|
||||
values: Measured values.
|
||||
values: Measured values, in chronological order.
|
||||
utl: Upper Tolerance Limit (None if not defined).
|
||||
ltl: Lower Tolerance Limit (None if not defined).
|
||||
nominal: Nominal value (None if not defined).
|
||||
@@ -81,39 +108,39 @@ def compute_capability(
|
||||
return CapabilityData(
|
||||
cp=None, cpk=None, pp=None, ppk=None,
|
||||
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,
|
||||
)
|
||||
|
||||
mean = stats.mean(values)
|
||||
# Population std dev for Pp/Ppk
|
||||
std_dev_pop = stats.pstdev(values)
|
||||
# Sample std dev for Cp/Cpk
|
||||
# Overall sample std dev (n-1) for Pp/Ppk
|
||||
std_dev_sample = stats.stdev(values)
|
||||
# Within-subgroup sigma (moving range) for Cp/Cpk
|
||||
sigma_within = _sigma_within(values)
|
||||
|
||||
cp = cpk = pp = ppk = None
|
||||
|
||||
if utl is not None and ltl is not None and std_dev_sample > 0:
|
||||
cp = round((utl - ltl) / (6 * std_dev_sample), 4)
|
||||
if sigma_within is not None:
|
||||
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 = []
|
||||
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:
|
||||
cpk_values.append((mean - ltl) / (3 * std_dev_sample))
|
||||
cpk_values.append((mean - ltl) / (3 * sigma_within))
|
||||
if cpk_values:
|
||||
cpk = round(min(cpk_values), 4)
|
||||
|
||||
if utl is not None and ltl is not None and std_dev_pop > 0:
|
||||
pp = round((utl - ltl) / (6 * std_dev_pop), 4)
|
||||
if std_dev_sample > 0:
|
||||
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 = []
|
||||
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:
|
||||
ppk_values.append((mean - ltl) / (3 * std_dev_pop))
|
||||
ppk_values.append((mean - ltl) / (3 * std_dev_sample))
|
||||
if ppk_values:
|
||||
ppk = round(min(ppk_values), 4)
|
||||
|
||||
@@ -121,6 +148,7 @@ def compute_capability(
|
||||
cp=cp, cpk=cpk, pp=pp, ppk=ppk,
|
||||
mean=round(mean, 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,
|
||||
utl=utl, ltl=ltl, nominal=nominal,
|
||||
)
|
||||
@@ -135,10 +163,12 @@ def compute_control_chart(
|
||||
ltl: float | None,
|
||||
nominal: float | None,
|
||||
) -> 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
|
||||
LCL = mean - 3*sigma
|
||||
UCL = mean + 3*sigma_within = mean + 2.66*MRbar
|
||||
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.
|
||||
|
||||
Args:
|
||||
@@ -160,10 +190,8 @@ def compute_control_chart(
|
||||
|
||||
mean = stats.mean(values)
|
||||
|
||||
if n >= 2:
|
||||
sigma = stats.stdev(values)
|
||||
else:
|
||||
sigma = 0.0
|
||||
sigma_within = _sigma_within(values)
|
||||
sigma = sigma_within if sigma_within is not None else 0.0
|
||||
|
||||
ucl = mean + 3 * sigma
|
||||
lcl = mean - 3 * sigma
|
||||
|
||||
@@ -138,6 +138,26 @@ async def unassign_recipe(
|
||||
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(
|
||||
db: AsyncSession, station_id: int,
|
||||
) -> list[Recipe]:
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
"""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.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,
|
||||
) -> 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)}
|
||||
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"{settings.vision_worker_url}/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}",
|
||||
) 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}",
|
||||
) 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",
|
||||
)
|
||||
|
||||
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.
|
||||
await measurement_service.validate_run_for_version(
|
||||
db, production_run_id, version_id,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
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"],
|
||||
executed_on="server",
|
||||
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.setting import SystemSetting, RecipeVersionAudit
|
||||
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
|
||||
@@ -142,7 +142,6 @@ async def _create_user(
|
||||
active: bool = True,
|
||||
) -> User:
|
||||
"""Insert a user into the test DB and return it with an API key set."""
|
||||
api_key = generate_api_key()
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
@@ -150,13 +149,14 @@ async def _create_user(
|
||||
roles=roles or [],
|
||||
is_admin=is_admin,
|
||||
active=active,
|
||||
api_key=api_key,
|
||||
language_pref="en",
|
||||
theme_pref="light",
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
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
|
||||
|
||||
|
||||
@@ -224,6 +224,10 @@ async def create_test_recipe(
|
||||
code=code,
|
||||
name=name,
|
||||
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,
|
||||
)
|
||||
session.add(recipe)
|
||||
@@ -243,6 +247,10 @@ async def create_test_recipe(
|
||||
version_id=version.id,
|
||||
order_index=0,
|
||||
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",
|
||||
description="First measurement task",
|
||||
)
|
||||
@@ -270,4 +278,4 @@ async def create_test_recipe(
|
||||
|
||||
def auth_headers(user: User) -> dict[str, str]:
|
||||
"""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
|
||||
|
||||
|
||||
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:
|
||||
"""Health check and auth requirement tests."""
|
||||
|
||||
@@ -94,3 +224,69 @@ class TestHealthAndAuth:
|
||||
resp = await client.get("/api/users")
|
||||
assert resp.status_code == 401
|
||||
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"
|
||||
@@ -0,0 +1,172 @@
|
||||
"""#40: after a reload, the quotes already measured in the cycle come back.
|
||||
|
||||
The cycle has no column of its own: it opens at the latest start, cycle_completed
|
||||
or remeasure-of-this-task event of the run, and a task_measured of the task after
|
||||
that boundary means the task is closed in this cycle.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||
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.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
T0 = datetime(2026, 10, 1, 8, 0, 0)
|
||||
|
||||
|
||||
async def _setup(db, user):
|
||||
recipe = await create_test_recipe(db, user.id)
|
||||
version = (await db.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)).scalar_one()
|
||||
task = (await db.execute(
|
||||
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||
)).scalars().first()
|
||||
first = (await db.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
second = RecipeSubtask(
|
||||
task_id=task.id, marker_number=2, description="Quota 2",
|
||||
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||
)
|
||||
db.add(second)
|
||||
station = Station(code="ST-CYC", name="St", active=True, created_by=user.id)
|
||||
db.add(station)
|
||||
await db.commit()
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=user.id, status="running", active_station_id=station.id,
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
return run, version, task, first, second
|
||||
|
||||
|
||||
async def _event(db, run, user, kind, minutes, task_id=None):
|
||||
db.add(ProductionEvent(
|
||||
run_id=run.id, event_type=kind, user_id=user.id, task_id=task_id,
|
||||
created_at=T0 + timedelta(minutes=minutes),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _meas(db, run, version, subtask, user, minutes, value=10.0):
|
||||
db.add(Measurement(
|
||||
subtask_id=subtask.id, version_id=version.id, measured_by=user.id,
|
||||
value=value, pass_fail="pass", production_run_id=run.id,
|
||||
measured_at=T0 + timedelta(minutes=minutes),
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _get(client, user, run, task):
|
||||
resp = await client.get(
|
||||
"/api/measurements/current-cycle", headers=auth_headers(user),
|
||||
params={"production_run_id": run.id, "task_id": task.id},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return resp.json()["measurements"]
|
||||
|
||||
|
||||
async def test_only_second_cycle_after_cycle_completed(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1, 10.1)
|
||||
await _meas(db_session, run, version, q2, u, 2, 10.2)
|
||||
await _event(db_session, run, u, "cycle_completed", 3)
|
||||
await _meas(db_session, run, version, q1, u, 4, 10.3)
|
||||
|
||||
rows = await _get(client, u, run, task)
|
||||
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.3)]
|
||||
assert rows[0]["pass_fail"] == "pass"
|
||||
|
||||
|
||||
async def test_last_reading_of_a_quote_wins(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1, 10.1)
|
||||
await _meas(db_session, run, version, q1, u, 2, 10.4)
|
||||
|
||||
rows = await _get(client, u, run, task)
|
||||
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.4)]
|
||||
|
||||
|
||||
async def test_remeasure_of_the_task_empties_the_list(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1)
|
||||
await _event(db_session, run, u, "remeasure", 2, task_id=task.id)
|
||||
|
||||
assert await _get(client, u, run, task) == []
|
||||
|
||||
|
||||
async def test_remeasure_of_another_task_does_not_move_the_boundary(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
other = RecipeTask(
|
||||
version_id=version.id, title="Altro", task_type=task.task_type, order_index=9,
|
||||
)
|
||||
db_session.add(other)
|
||||
await db_session.commit()
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1)
|
||||
await _event(db_session, run, u, "remeasure", 2, task_id=other.id)
|
||||
|
||||
assert len(await _get(client, u, run, task)) == 1
|
||||
|
||||
|
||||
async def test_task_measured_after_boundary_means_closed(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _meas(db_session, run, version, q1, u, 1)
|
||||
await _meas(db_session, run, version, q2, u, 2)
|
||||
await _event(db_session, run, u, "task_measured", 3, task_id=task.id)
|
||||
|
||||
assert await _get(client, u, run, task) == []
|
||||
|
||||
|
||||
async def test_task_measured_before_boundary_does_not_close_next_cycle(
|
||||
client: AsyncClient, db_session, measurement_tec_user,
|
||||
):
|
||||
u = measurement_tec_user
|
||||
run, version, task, q1, q2 = await _setup(db_session, u)
|
||||
await _event(db_session, run, u, "start", 0)
|
||||
await _event(db_session, run, u, "task_measured", 1, task_id=task.id)
|
||||
await _event(db_session, run, u, "cycle_completed", 2)
|
||||
await _meas(db_session, run, version, q1, u, 3)
|
||||
|
||||
assert len(await _get(client, u, run, task)) == 1
|
||||
|
||||
|
||||
async def test_permissions(client: AsyncClient, db_session, measurement_tec_user, maker_user):
|
||||
run, _v, task, _q1, _q2 = await _setup(db_session, measurement_tec_user)
|
||||
params = {"production_run_id": run.id, "task_id": task.id}
|
||||
|
||||
no_key = await client.get("/api/measurements/current-cycle", params=params)
|
||||
assert no_key.status_code in (401, 403)
|
||||
|
||||
forbidden = await client.get(
|
||||
"/api/measurements/current-cycle", headers=auth_headers(maker_user), params=params,
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
@@ -7,9 +7,13 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
class TestUploadFile:
|
||||
@@ -61,6 +65,51 @@ class TestUploadFile:
|
||||
assert data["file_type"] == "image/jpeg"
|
||||
assert data["file_size"] > 0
|
||||
|
||||
async def test_extension_forced_to_detected_type_ignores_filename(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""gitea#36 point 2: the stored extension follows the detected magic
|
||||
bytes, not the client-supplied filename — even a misleading one."""
|
||||
jpeg_bytes = b"\xff\xd8\xff\xe0\x00\x10JFIF" + b"\x00" * 20 + b"\xff\xd9"
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
|
||||
resp = await client.post(
|
||||
"/api/files/upload",
|
||||
headers=auth_headers(maker_user),
|
||||
# Filename claims .txt; content is a real JPEG.
|
||||
files={"file": ("photo.txt", io.BytesIO(jpeg_bytes), "text/plain")},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["file_type"] == "image/jpeg"
|
||||
assert data["file_path"].endswith(".jpg")
|
||||
|
||||
async def test_upload_declared_type_lies_about_html_content(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""gitea#36 point 2: a file whose bytes are HTML must be rejected even
|
||||
when the client declares Content-Type: image/png and names it .png —
|
||||
this is what previously let a stored file be served back as
|
||||
text/html by extension and executed by the browser."""
|
||||
html_bytes = b"<html><body><script>alert(1)</script></body></html>"
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
|
||||
resp = await client.post(
|
||||
"/api/files/upload",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"file": ("x.png", io.BytesIO(html_bytes), "image/png")},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert not any(tmp_path.rglob("x.png"))
|
||||
|
||||
async def test_upload_invalid_type(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
@@ -134,6 +183,79 @@ class TestGetFile:
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestGetRestrictedFile:
|
||||
"""gitea#21: GET statistics/* and reports/* need Metrologist/Supervisor/admin."""
|
||||
|
||||
async def test_measurement_tec_forbidden_on_statistics(
|
||||
self, client: AsyncClient, measurement_tec_user: User, tmp_path: Path
|
||||
):
|
||||
stats_dir = tmp_path / "statistics"
|
||||
stats_dir.mkdir()
|
||||
(stats_dir / "production_1_x.csv").write_text("a,b\n1,2\n")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
"/api/files/statistics/production_1_x.csv",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_metrologist_allowed_on_statistics(
|
||||
self, client: AsyncClient, metrologist_user: User, tmp_path: Path
|
||||
):
|
||||
stats_dir = tmp_path / "statistics"
|
||||
stats_dir.mkdir()
|
||||
(stats_dir / "production_1_x.csv").write_text("a,b\n1,2\n")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
"/api/files/statistics/production_1_x.csv",
|
||||
headers=auth_headers(metrologist_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_admin_allowed_on_reports(
|
||||
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
||||
):
|
||||
reports_dir = tmp_path / "reports"
|
||||
reports_dir.mkdir()
|
||||
(reports_dir / "spc_1.pdf").write_bytes(b"%PDF-1.4")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
"/api/files/reports/spc_1.pdf",
|
||||
headers=auth_headers(admin_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_maker_unrestricted_path_still_allowed(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""The restriction is scoped to statistics/reports, not every file."""
|
||||
test_file = tmp_path / "1" / "1" / "drawing.png"
|
||||
test_file.parent.mkdir(parents=True)
|
||||
test_file.write_bytes(b"\x89PNG")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.get(
|
||||
"/api/files/1/1/drawing.png",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestDeleteFile:
|
||||
"""DELETE /api/files/{file_path} tests."""
|
||||
|
||||
@@ -155,6 +277,242 @@ class TestDeleteFile:
|
||||
assert resp.status_code == 204
|
||||
assert not test_file.exists()
|
||||
|
||||
async def test_delete_unreferenced_recipe_image_allowed(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""A file under a recipe/version path but referenced nowhere is safe."""
|
||||
test_file = tmp_path / "5" / "12" / "images" / "orphan.png"
|
||||
test_file.parent.mkdir(parents=True)
|
||||
test_file.write_bytes(b"\x89PNG")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/files/5/12/images/orphan.png",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert not test_file.exists()
|
||||
|
||||
|
||||
class TestDeleteProtectedFolders:
|
||||
"""gitea#21: statistics/, reports/, logos/ can never be deleted here."""
|
||||
|
||||
async def test_maker_delete_statistics_forbidden(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
stats_dir = tmp_path / "statistics"
|
||||
stats_dir.mkdir()
|
||||
test_file = stats_dir / "production_1_x.csv"
|
||||
test_file.write_text("a,b\n1,2\n")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/files/statistics/production_1_x.csv",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert test_file.exists()
|
||||
|
||||
async def test_admin_delete_reports_forbidden(
|
||||
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
||||
):
|
||||
"""Even an admin cannot delete through this route: not just a role gate."""
|
||||
reports_dir = tmp_path / "reports"
|
||||
reports_dir.mkdir()
|
||||
test_file = reports_dir / "spc_1.pdf"
|
||||
test_file.write_bytes(b"%PDF-1.4")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/files/reports/spc_1.pdf",
|
||||
headers=auth_headers(admin_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert test_file.exists()
|
||||
|
||||
async def test_delete_logo_forbidden(
|
||||
self, client: AsyncClient, maker_user: User, tmp_path: Path
|
||||
):
|
||||
"""The logo is only ever replaced via POST /api/settings/logo."""
|
||||
logos_dir = tmp_path / "logos"
|
||||
logos_dir.mkdir()
|
||||
test_file = logos_dir / "company_logo.png"
|
||||
test_file.write_bytes(b"\x89PNG")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/files/logos/company_logo.png",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert test_file.exists()
|
||||
|
||||
|
||||
class TestDeleteReferencedRecipeFile:
|
||||
"""gitea#21: a recipe file backing the current version, a historical
|
||||
version, or measured data must survive a DELETE (409)."""
|
||||
|
||||
async def test_current_version_no_measurements_is_allowed(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""Still a draft (current version, nothing measured against it yet):
|
||||
swapping the drawing out is exactly what editing a recipe does."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-A")
|
||||
version = recipe.versions[0]
|
||||
task = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||
)).scalar_one()
|
||||
task.file_path = "recipe-a/1/drawing.png"
|
||||
await db_session.commit()
|
||||
|
||||
test_file = tmp_path / "recipe-a" / "1" / "drawing.png"
|
||||
test_file.parent.mkdir(parents=True)
|
||||
test_file.write_bytes(b"\x89PNG")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/files/recipe-a/1/drawing.png",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert not test_file.exists()
|
||||
|
||||
async def test_current_version_with_measurements_is_409(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""Once something has been measured against this version, its drawing
|
||||
must stay: the audit trail needs it to make sense of the numbers."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-D")
|
||||
version = recipe.versions[0]
|
||||
task = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||
)).scalar_one()
|
||||
task.file_path = "recipe-d/1/drawing.png"
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
|
||||
subtask = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalar_one()
|
||||
db_session.add(Measurement(
|
||||
subtask_id=subtask.id,
|
||||
version_id=version.id,
|
||||
measured_by=maker_user.id,
|
||||
value=10.0,
|
||||
pass_fail="pass",
|
||||
input_method="manual",
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
test_file = tmp_path / "recipe-d" / "1" / "drawing.png"
|
||||
test_file.parent.mkdir(parents=True)
|
||||
test_file.write_bytes(b"\x89PNG")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/files/recipe-d/1/drawing.png",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert test_file.exists()
|
||||
|
||||
async def test_noncurrent_version_task_file_is_409(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""A file kept only for a superseded version is history, not garbage."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-B")
|
||||
|
||||
old_version = RecipeVersion(
|
||||
recipe_id=recipe.id,
|
||||
version_number=2,
|
||||
is_current=False,
|
||||
created_by=maker_user.id,
|
||||
change_notes="Superseded",
|
||||
)
|
||||
db_session.add(old_version)
|
||||
await db_session.flush()
|
||||
old_task = RecipeTask(
|
||||
version_id=old_version.id,
|
||||
order_index=0,
|
||||
title="Old drawing",
|
||||
task_type="drawing",
|
||||
file_path="recipe-b/2/old-drawing.png",
|
||||
)
|
||||
db_session.add(old_task)
|
||||
await db_session.commit()
|
||||
|
||||
test_file = tmp_path / "recipe-b" / "2" / "old-drawing.png"
|
||||
test_file.parent.mkdir(parents=True)
|
||||
test_file.write_bytes(b"\x89PNG")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/files/recipe-b/2/old-drawing.png",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert test_file.exists()
|
||||
|
||||
async def test_recipe_cover_image_is_409(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
tmp_path: Path,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="FILE-409-C")
|
||||
recipe.image_path = "recipe-c/cover.png"
|
||||
await db_session.commit()
|
||||
|
||||
test_file = tmp_path / "recipe-c" / "cover.png"
|
||||
test_file.parent.mkdir(parents=True)
|
||||
test_file.write_bytes(b"\x89PNG")
|
||||
|
||||
with patch("src.backend.api.routers.files.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/files/recipe-c/cover.png",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
|
||||
assert resp.status_code == 409
|
||||
assert test_file.exists()
|
||||
|
||||
|
||||
class TestFilenameSanitization:
|
||||
"""Path traversal protection tests."""
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"""Tests for measurements router (/api/measurements)."""
|
||||
import csv
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.services import auth_service
|
||||
from src.backend.tests.conftest import _create_user, auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _get_subtask_and_version(
|
||||
@@ -223,6 +227,281 @@ class TestMeasurementPassFail:
|
||||
assert resp.json()["pass_fail"] == "fail"
|
||||
|
||||
|
||||
class TestMeasurementValueBounds:
|
||||
"""value rejects NaN/Infinity and out-of-DECIMAL(12,6)-scale numbers (#20)."""
|
||||
|
||||
@pytest.mark.parametrize("bad_value", [1e300, 1_000_000])
|
||||
async def test_out_of_scale_value_is_422(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
bad_value: float,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": bad_value,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@pytest.mark.parametrize("raw_token", ["NaN", "Infinity", "-Infinity"])
|
||||
async def test_nan_and_infinity_are_422(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
raw_token: str,
|
||||
):
|
||||
"""NaN/Infinity are not valid JSON: httpx's own encoder refuses to send
|
||||
them via ``json=``, so the raw body is built by hand here - exactly
|
||||
what a client that is not httpx could still put on the wire."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
body = (
|
||||
'{"subtask_id": %d, "version_id": %d, "value": %s}'
|
||||
% (subtask_id, version_id, raw_token)
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers={**auth_headers(measurement_tec_user), "Content-Type": "application/json"},
|
||||
content=body,
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_value_is_rounded_before_the_verdict(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""10.5000004 rounds to 10.5 (=utl) first, so it is not a fail."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.5000004,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["value"] == pytest.approx(10.5)
|
||||
# 10.5 is > uwl(10.3) but not > utl(10.5): warning, not fail.
|
||||
assert data["pass_fail"] == "warning"
|
||||
|
||||
|
||||
class TestSaveMeasurementCoherence:
|
||||
"""save_measurement checks quote/version/run coherence before insert (#5)."""
|
||||
|
||||
async def _station(self, db_session, user_id, code):
|
||||
station = Station(
|
||||
code=code, name=f"Stazione {code}", active=True, created_by=user_id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(station)
|
||||
return station
|
||||
|
||||
async def _supervisor(self, db_session, username):
|
||||
user = await auth_service.create_user(
|
||||
db_session, username=username, password="TurnoPwd1",
|
||||
display_name="Capo Turno", roles=["Supervisor"],
|
||||
)
|
||||
await db_session.commit()
|
||||
return user, "TurnoPwd1"
|
||||
|
||||
async def test_quota_of_another_recipe_version_is_422(
|
||||
self, client: AsyncClient, measurement_tec_user: User, db_session: AsyncSession,
|
||||
):
|
||||
recipe_a = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-A")
|
||||
recipe_b = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-B")
|
||||
subtask_a, _version_a = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe_a.id
|
||||
)
|
||||
_subtask_b, version_b = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe_b.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"subtask_id": subtask_a, "version_id": version_b, "value": 10.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_measurement_of_other_recipe_in_run_is_422(
|
||||
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
recipe_a = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id, code="COH-RUNA"
|
||||
)
|
||||
recipe_b = await create_test_recipe(
|
||||
db_session, measurement_tec_user.id, code="COH-RUNB"
|
||||
)
|
||||
station = await self._station(db_session, admin_user.id, "ST-COH1")
|
||||
|
||||
run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe_a.id},
|
||||
)).json()
|
||||
|
||||
subtask_b, version_b = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe_b.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_b,
|
||||
"version_id": version_b,
|
||||
"value": 10.0,
|
||||
"production_run_id": run["id"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_measurement_on_closed_run_is_409(
|
||||
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-CLOSED")
|
||||
station = await self._station(db_session, admin_user.id, "ST-COH2")
|
||||
run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
supervisor, password = await self._supervisor(db_session, "capo-coh-close")
|
||||
await client.post(
|
||||
f"/api/production-runs/{run['id']}/close",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": password,
|
||||
},
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.0,
|
||||
"production_run_id": run["id"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_measurement_on_paused_run_is_409(
|
||||
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-PAUSED")
|
||||
station = await self._station(db_session, admin_user.id, "ST-COH3")
|
||||
run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
supervisor, password = await self._supervisor(db_session, "capo-coh-pause")
|
||||
await client.post(
|
||||
f"/api/production-runs/{run['id']}/pause",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": password,
|
||||
},
|
||||
)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.0,
|
||||
"production_run_id": run["id"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_valid_measurement_in_matching_run_is_200(
|
||||
self, client: AsyncClient, measurement_tec_user: User, admin_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-OK")
|
||||
station = await self._station(db_session, admin_user.id, "ST-COH4")
|
||||
run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.0,
|
||||
"production_run_id": run["id"],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_measurement_on_unknown_run_is_404(
|
||||
self, client: AsyncClient, measurement_tec_user: User, db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="COH-404")
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.0,
|
||||
"production_run_id": 999999,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestListMeasurements:
|
||||
"""GET /api/measurements/ tests."""
|
||||
|
||||
@@ -366,3 +645,103 @@ class TestMeasurementStatistics:
|
||||
# deviation = 10.2 - nominal(10.0) = 0.2
|
||||
assert data["deviation"] is not None
|
||||
assert abs(data["deviation"] - 0.2) < 0.001
|
||||
|
||||
|
||||
class TestExportCsv:
|
||||
"""GET /api/measurements/export/csv tests (gitea#22, gitea#36 point 3)."""
|
||||
|
||||
async def test_admin_without_explicit_role_can_export(
|
||||
self, client: AsyncClient, db_session: AsyncSession,
|
||||
):
|
||||
"""An admin with no MeasurementTec/Metrologist role must not get 403 (#36.3).
|
||||
|
||||
admin_user (conftest) already has both roles, which would pass even
|
||||
without the fix: a bare admin (roles=[]) is the actual regression case.
|
||||
"""
|
||||
bare_admin = await _create_user(
|
||||
db_session, username="bare_admin", roles=[], is_admin=True,
|
||||
)
|
||||
resp = await client.get(
|
||||
"/api/measurements/export/csv",
|
||||
headers=auth_headers(bare_admin),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_maker_without_role_still_forbidden(
|
||||
self, client: AsyncClient, maker_user: User,
|
||||
):
|
||||
"""A plain Maker (no MeasurementTec/Metrologist/admin) still gets 403."""
|
||||
resp = await client.get(
|
||||
"/api/measurements/export/csv",
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_lot_number_formula_is_neutralised(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""A lot_number starting with '=' must not reach the CSV as a live formula (#22)."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": 10.0,
|
||||
"lot_number": '=HYPERLINK("http://x","y")',
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
export_resp = await client.get(
|
||||
"/api/measurements/export/csv",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert export_resp.status_code == 200
|
||||
rows = list(csv.reader(export_resp.text.splitlines()))
|
||||
header, row = rows[0], rows[1]
|
||||
lot_cell = row[header.index("lot_number")]
|
||||
# Quoted with a leading ' so a spreadsheet reads it as literal text,
|
||||
# not the raw formula that would otherwise be live in Excel/LibreOffice.
|
||||
assert lot_cell == '\'=HYPERLINK("http://x","y")'
|
||||
|
||||
async def test_negative_value_stays_numeric(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Neutralisation must never touch numeric cells like the measured value."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
subtask_id, version_id = await _get_subtask_and_version(
|
||||
client, measurement_tec_user, recipe.id
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"subtask_id": subtask_id,
|
||||
"version_id": version_id,
|
||||
"value": -1.25,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
export_resp = await client.get(
|
||||
"/api/measurements/export/csv",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert export_resp.status_code == 200
|
||||
rows = list(csv.reader(export_resp.text.splitlines()))
|
||||
header, row = rows[0], rows[1]
|
||||
value_cell = row[header.index("value")]
|
||||
assert value_cell == "-1.250000"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Tests for the Alembic migration chain, and specifically for
|
||||
000_base_schema.py (issue #6): a genuinely empty database has no starting
|
||||
point before it, since 001-013 all assume tables 001 was written against
|
||||
already exist.
|
||||
|
||||
These run the real `alembic upgrade head` (via `alembic.command`), not the
|
||||
async app engine: `migrations/env.py` builds its own engine from
|
||||
`settings.database_url`, so `Settings.database_url` is patched at the class
|
||||
level (a plain instance monkeypatch cannot override a `@property`) to point
|
||||
at a throwaway SQLite file for the duration of each test.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
||||
|
||||
from src.backend.config import Settings
|
||||
from src.backend.database import Base
|
||||
|
||||
_ALEMBIC_INI = (
|
||||
Path(__file__).resolve().parents[1] / "migrations" / "alembic.ini"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_database_url(tmp_path, monkeypatch):
|
||||
"""Point settings.database_url (and therefore migrations/env.py) at a
|
||||
throwaway SQLite file, and return its async URL."""
|
||||
db_path = tmp_path / "migration_test.db"
|
||||
async_url = f"sqlite+aiosqlite:///{db_path}"
|
||||
monkeypatch.setattr(Settings, "database_url", property(lambda self: async_url))
|
||||
return db_path, async_url
|
||||
|
||||
|
||||
def _alembic_config() -> Config:
|
||||
return Config(str(_ALEMBIC_INI))
|
||||
|
||||
|
||||
def _inspect(db_path: Path):
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
try:
|
||||
return sa_inspect(engine)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
class TestUpgradeHeadFromEmpty:
|
||||
"""Full chain on a genuinely empty SQLite database (#6)."""
|
||||
|
||||
def test_upgrade_head_succeeds_on_empty_database(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
command.upgrade(_alembic_config(), "head") # must not raise
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert set(Base.metadata.tables) <= set(insp.get_table_names())
|
||||
|
||||
def test_tables_and_columns_match_base_metadata(self, sqlite_database_url):
|
||||
"""Every table and column ORM models declare must exist after
|
||||
`upgrade head` - the exact assertion the analysis asks for."""
|
||||
db_path, _ = sqlite_database_url
|
||||
command.upgrade(_alembic_config(), "head")
|
||||
|
||||
insp = _inspect(db_path)
|
||||
db_tables = set(insp.get_table_names())
|
||||
|
||||
for table_name, table in Base.metadata.tables.items():
|
||||
assert table_name in db_tables, f"missing table {table_name}"
|
||||
db_columns = {c["name"] for c in insp.get_columns(table_name)}
|
||||
model_columns = {c.name for c in table.columns}
|
||||
missing = model_columns - db_columns
|
||||
assert not missing, f"{table_name} missing columns: {missing}"
|
||||
|
||||
|
||||
class TestBaseMigrationNoopsOnExistingSchema:
|
||||
"""000_base_schema.py does nothing when `recipes` already exists (#6)."""
|
||||
|
||||
def test_upgrade_head_noops_000_when_pre_001_schema_already_there(
|
||||
self, sqlite_database_url
|
||||
):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
# Build the pre-001 schema and record it as migrated.
|
||||
command.upgrade(cfg, "000_base")
|
||||
insp = _inspect(db_path)
|
||||
assert "recipes" in insp.get_table_names()
|
||||
assert "image_path" not in {
|
||||
c["name"] for c in insp.get_columns("recipes")
|
||||
}
|
||||
|
||||
# Erase the tracking: alembic now believes nothing has run, exactly
|
||||
# like a database that was born from Base.metadata.create_all() and
|
||||
# never stamped. Running 000's upgrade() again on a database that
|
||||
# already has `recipes` must be a no-op, not a "table already
|
||||
# exists" failure.
|
||||
sync_engine = create_engine(f"sqlite:///{db_path}")
|
||||
with sync_engine.begin() as conn:
|
||||
conn.execute(text("DELETE FROM alembic_version"))
|
||||
sync_engine.dispose()
|
||||
|
||||
command.upgrade(cfg, "head") # must not raise (no duplicate table)
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert set(Base.metadata.tables) <= set(insp.get_table_names())
|
||||
assert "image_path" in {
|
||||
c["name"] for c in insp.get_columns("recipes")
|
||||
}
|
||||
|
||||
|
||||
class TestApiKeyHashMigration:
|
||||
"""015_api_key_hash: users.api_key -> api_keys table, and back (#41)."""
|
||||
|
||||
def test_015_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" in insp.get_table_names()
|
||||
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||
assert {c["name"] for c in insp.get_columns("api_keys")} >= {
|
||||
"user_id", "key_hash", "created_at", "expires_at", "last_used_at",
|
||||
}
|
||||
|
||||
command.downgrade(cfg, "014_vision_fk_ondelete")
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" not in insp.get_table_names()
|
||||
assert "api_key" in {c["name"] for c in insp.get_columns("users")}
|
||||
|
||||
command.upgrade(cfg, "head") # and up again
|
||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||
|
||||
|
||||
class TestVisionRecipeDirMigration:
|
||||
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
||||
|
||||
def test_016_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def cols():
|
||||
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert "vision_recipe_dir" in cols()
|
||||
command.downgrade(cfg, "015_api_key_hash")
|
||||
assert "vision_recipe_dir" not in cols()
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert "vision_recipe_dir" in cols()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""GET /api/production-runs/open - live monitor of the open runs (#57)."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.tests.conftest import _create_user, auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _run(db, user, code, status="running", overdue=False, open_=True):
|
||||
"""A station plus a run on a fresh recipe (limits ltl 9.5 / utl 10.5)."""
|
||||
station = Station(code=f"ST-{code}", name=f"Linea {code}", active=True, created_by=user.id)
|
||||
db.add(station)
|
||||
recipe = await create_test_recipe(db, user_id=user.id, code=f"REC-{code}")
|
||||
await db.flush()
|
||||
version = recipe.versions[0]
|
||||
now = datetime.utcnow()
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=user.id, status=status if open_ else "closed",
|
||||
started_at=now - timedelta(hours=1),
|
||||
measurement_interval_minutes=30,
|
||||
next_measurement_at=now + (timedelta(minutes=-5) if overdue else timedelta(minutes=20)),
|
||||
cycle_count=3,
|
||||
active_station_id=station.id if open_ else None,
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
db.add(ProductionEvent(run_id=run.id, event_type="start", user_id=user.id))
|
||||
sub = (await db.execute(
|
||||
select(RecipeSubtask).join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
|
||||
.where(RecipeTask.version_id == version.id)
|
||||
)).scalars().first()
|
||||
return run, sub, version
|
||||
|
||||
|
||||
def _m(run, sub, version, user, value, pf, minutes=0):
|
||||
return Measurement(
|
||||
subtask_id=sub.id, version_id=version.id, measured_by=user.id, value=value,
|
||||
pass_fail=pf, production_run_id=run.id,
|
||||
measured_at=datetime.utcnow() - timedelta(minutes=minutes),
|
||||
)
|
||||
|
||||
|
||||
async def test_open_requires_high_authority(client, db_session, measurement_tec_user):
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(measurement_tec_user))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_open_lists_only_open_runs_with_means(client, db_session, admin_user):
|
||||
sup = await _create_user(db_session, "capo", roles=["Supervisor"])
|
||||
r1, s1, v1 = await _run(db_session, admin_user, "A", overdue=True)
|
||||
r2, s2, v2 = await _run(db_session, admin_user, "B", status="paused")
|
||||
await _run(db_session, admin_user, "C", open_=False)
|
||||
# Run A: mean 10.0 (inside), run B: mean 10.8 (outside, one fail).
|
||||
for i, (val, pf) in enumerate([(9.9, "pass"), (10.1, "pass")]):
|
||||
db_session.add(_m(r1, s1, v1, admin_user, val, pf, i))
|
||||
for i, (val, pf) in enumerate([(10.6, "fail"), (11.0, "fail"), (10.8, "warning")]):
|
||||
db_session.add(_m(r2, s2, v2, admin_user, val, pf, i))
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(sup))
|
||||
assert resp.status_code == 200
|
||||
body = {r["station_code"]: r for r in resp.json()}
|
||||
assert set(body) == {"ST-A", "ST-B"} # closed run C is absent
|
||||
a, b = body["ST-A"], body["ST-B"]
|
||||
assert a["overdue"] is True and a["seconds_to_next_measurement"] < 0
|
||||
assert b["overdue"] is False and b["status"] == "paused"
|
||||
assert a["duration_seconds"] >= 3600 and a["cycle_count"] == 3
|
||||
assert a["last_event"]["event_type"] == "start"
|
||||
assert a["pass_count"] == 2 and a["fail_count"] == 0
|
||||
assert a["subtasks"][0]["mean"] == 10.0 and a["subtasks"][0]["in_tolerance"] is True
|
||||
assert b["fail_count"] == 2 and b["warning_count"] == 1
|
||||
assert round(b["subtasks"][0]["mean"], 3) == 10.8
|
||||
assert b["subtasks"][0]["in_tolerance"] is False
|
||||
|
||||
|
||||
async def test_open_empty_for_admin(client, admin_user):
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 200 and resp.json() == []
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Point 5: a quote out of tolerance stops the line until someone says otherwise.
|
||||
|
||||
The gate existed on screen and nowhere else - a modal that opened, checked a
|
||||
password and closed, while the measurement had already been saved. Dismissing it
|
||||
was enough to carry on. These tests are about the part that cannot be dismissed.
|
||||
"""
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
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.services import auth_service
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
GOOD = 10.0 # inside every limit of the fixture quote
|
||||
OUT = 99.0 # far outside
|
||||
|
||||
|
||||
async def _supervisor(db_session, username="capo-tol", password="TurnoPwd1"):
|
||||
user = await auth_service.create_user(
|
||||
db_session, username=username, password=password,
|
||||
display_name="Capo Turno", roles=["Supervisor"],
|
||||
)
|
||||
await db_session.commit()
|
||||
return user, password
|
||||
|
||||
|
||||
async def _two_quotes(db_session, recipe_id: int):
|
||||
"""The fixture recipe plus a second quote, so there is a 'next' one."""
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe_id,
|
||||
RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)).scalar_one()
|
||||
task = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||
)).scalars().first()
|
||||
first = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
|
||||
second = RecipeSubtask(
|
||||
task_id=task.id, marker_number=2, description="Seconda quota",
|
||||
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
|
||||
)
|
||||
db_session.add(second)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(second)
|
||||
return version.id, first, second
|
||||
|
||||
|
||||
async def _measure(client, user, subtask_id, version_id, value, run_id=None):
|
||||
body = {"subtask_id": subtask_id, "version_id": version_id, "value": value}
|
||||
if run_id is not None:
|
||||
body["production_run_id"] = run_id
|
||||
return await client.post(
|
||||
"/api/measurements/", headers=auth_headers(user), json=body,
|
||||
)
|
||||
|
||||
|
||||
async def _pending(client, user, version_id, run_id=None):
|
||||
params = {"version_id": version_id}
|
||||
if run_id is not None:
|
||||
params["production_run_id"] = run_id
|
||||
return await client.get(
|
||||
"/api/measurements/pending-authorisation",
|
||||
headers=auth_headers(user), params=params,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The block
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_next_quote_is_refused_while_one_is_out_of_tolerance(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-NEXT")
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
bad = await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||
assert bad.status_code == 200
|
||||
assert bad.json()["pass_fail"] == "fail"
|
||||
|
||||
refused = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||
assert refused.status_code == 409
|
||||
assert "supervisor" in refused.json()["detail"].lower()
|
||||
|
||||
|
||||
async def test_the_same_quote_can_be_measured_again(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""The caliper slips, the piece is reseated. Trying again is not going on."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-AGAIN")
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||
again = await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||
assert again.status_code == 200, "rimisurare la stessa quota deve restare possibile"
|
||||
|
||||
|
||||
async def test_a_good_reading_of_the_same_quote_releases_the_block(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""Only the latest reading of a quote counts: it is in tolerance now."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-FIXED")
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
await _measure(client, measurement_tec_user, first.id, version_id, OUT)
|
||||
await _measure(client, measurement_tec_user, first.id, version_id, GOOD)
|
||||
|
||||
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||
assert ahead.status_code == 200
|
||||
|
||||
# Both readings stay on record - how many are allowed is point 4's business.
|
||||
rows = (await db_session.execute(
|
||||
select(Measurement).where(Measurement.subtask_id == first.id)
|
||||
)).scalars().all()
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
async def test_a_warning_does_not_block(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""Outside the warning limits but inside tolerance is still inside tolerance."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-WARN")
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
warned = await _measure(client, measurement_tec_user, first.id, version_id, 10.4)
|
||||
assert warned.json()["pass_fail"] == "warning"
|
||||
|
||||
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||
assert ahead.status_code == 200
|
||||
|
||||
|
||||
async def test_pending_authorisation_names_the_blocking_quote(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
"""What the screen asks on load, so a reload is not a way past the gate."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-PEND")
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
assert (await _pending(client, measurement_tec_user, version_id)).json() is None
|
||||
|
||||
bad = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT,
|
||||
)).json()
|
||||
|
||||
pending = (await _pending(client, measurement_tec_user, version_id)).json()
|
||||
assert pending["id"] == bad["id"]
|
||||
assert pending["subtask_id"] == first.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The authorisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_supervisor_authorisation_is_recorded_and_releases_the_block(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-AUTH")
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
supervisor, password = await _supervisor(db_session)
|
||||
|
||||
bad = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT,
|
||||
)).json()
|
||||
|
||||
authorised = await client.post(
|
||||
f"/api/measurements/{bad['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": password,
|
||||
},
|
||||
)
|
||||
assert authorised.status_code == 200, authorised.text
|
||||
body = authorised.json()
|
||||
# Who said yes is on the measurement itself, which is the whole point.
|
||||
assert body["supervisor_id"] == supervisor.id
|
||||
assert body["authorised_at"] is not None
|
||||
|
||||
ahead = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||
assert ahead.status_code == 200
|
||||
|
||||
|
||||
async def test_authorisation_refuses_someone_who_is_not_a_supervisor(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-NOSUP")
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
bad = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT,
|
||||
)).json()
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/measurements/{bad['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": maker_user.username,
|
||||
"supervisor_password": "testpassword123",
|
||||
},
|
||||
)
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
# And the block is still there.
|
||||
refused = await _measure(client, measurement_tec_user, second.id, version_id, GOOD)
|
||||
assert refused.status_code == 409
|
||||
|
||||
|
||||
async def test_authorisation_refuses_wrong_credentials(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-BADPW")
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
supervisor, _password = await _supervisor(db_session, username="capo-badpw")
|
||||
|
||||
bad = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT,
|
||||
)).json()
|
||||
resp = await client.post(
|
||||
f"/api/measurements/{bad['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": "sbagliata",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_there_is_nothing_to_authorise_on_a_good_measurement(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-GOOD")
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
supervisor, password = await _supervisor(db_session, username="capo-good")
|
||||
|
||||
good = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, GOOD,
|
||||
)).json()
|
||||
resp = await client.post(
|
||||
f"/api/measurements/{good['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": password,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The production run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_cycle_cannot_close_over_an_unauthorised_fail(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""Closing the cycle would be another way of walking past the quote."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-CYC")
|
||||
recipe.measurement_interval_minutes = 10
|
||||
station = Station(
|
||||
code="ST-TOL", name="Stazione", active=True, created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
|
||||
await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT, run_id=run["id"],
|
||||
)
|
||||
|
||||
refused = await client.post(
|
||||
f"/api/production-runs/{run['id']}/cycle",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert refused.status_code == 409
|
||||
|
||||
supervisor, password = await _supervisor(db_session, username="capo-cyc")
|
||||
blocking = (await _pending(
|
||||
client, measurement_tec_user, version_id, run_id=run["id"],
|
||||
)).json()
|
||||
await client.post(
|
||||
f"/api/measurements/{blocking['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": password,
|
||||
},
|
||||
)
|
||||
|
||||
allowed = await client.post(
|
||||
f"/api/production-runs/{run['id']}/cycle",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
)
|
||||
assert allowed.status_code == 200
|
||||
assert allowed.json()["cycle_count"] == 1
|
||||
|
||||
|
||||
async def test_the_statistics_file_carries_the_decision(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
tmp_path, monkeypatch,
|
||||
):
|
||||
"""The file has to show the failure and who let it stand, not just the failure."""
|
||||
from src.backend.config import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||||
)
|
||||
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-CSV")
|
||||
station = Station(
|
||||
code="ST-CSV", name="Stazione", active=True, created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
version_id, first, _second = await _two_quotes(db_session, recipe.id)
|
||||
supervisor, password = await _supervisor(db_session, username="capo-csv")
|
||||
creds = {
|
||||
"supervisor_username": supervisor.username, "supervisor_password": password,
|
||||
}
|
||||
|
||||
run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
bad = (await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT, run_id=run["id"],
|
||||
)).json()
|
||||
await client.post(
|
||||
f"/api/measurements/{bad['id']}/authorise",
|
||||
headers=auth_headers(measurement_tec_user), json=creds,
|
||||
)
|
||||
|
||||
closed = (await client.post(
|
||||
f"/api/production-runs/{run['id']}/close",
|
||||
headers=auth_headers(measurement_tec_user), json=creds,
|
||||
)).json()
|
||||
|
||||
text = (tmp_path / closed["statistics_path"]).read_text(encoding="utf-8-sig")
|
||||
header, row = [line for line in text.splitlines() if line.strip()]
|
||||
assert "authorised_by" in header and "authorised_at" in header
|
||||
assert str(supervisor.id) in row
|
||||
|
||||
|
||||
async def test_a_fail_from_another_production_does_not_block_this_one(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
tmp_path, monkeypatch,
|
||||
):
|
||||
"""The run is the boundary: an old production must not stop a new one."""
|
||||
# Closing a run writes the statistics file; pointed at tmp_path so the test
|
||||
# does not leave one in the repository's uploads directory.
|
||||
from src.backend.config import settings
|
||||
|
||||
monkeypatch.setattr(
|
||||
type(settings), "upload_path", property(lambda self: tmp_path), raising=False,
|
||||
)
|
||||
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-SCOPE")
|
||||
station = Station(
|
||||
code="ST-SCOPE", name="Stazione", active=True, created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
version_id, first, second = await _two_quotes(db_session, recipe.id)
|
||||
|
||||
first_run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
await _measure(
|
||||
client, measurement_tec_user, first.id, version_id, OUT,
|
||||
run_id=first_run["id"],
|
||||
)
|
||||
|
||||
supervisor, password = await _supervisor(db_session, username="capo-scope")
|
||||
await client.post(
|
||||
f"/api/production-runs/{first_run['id']}/close",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={
|
||||
"supervisor_username": supervisor.username,
|
||||
"supervisor_password": password,
|
||||
},
|
||||
)
|
||||
|
||||
second_run = (await client.post(
|
||||
"/api/production-runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": station.code, "recipe_id": recipe.id},
|
||||
)).json()
|
||||
resp = await _measure(
|
||||
client, measurement_tec_user, second.id, version_id, GOOD,
|
||||
run_id=second_run["id"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Unit tests for production_export_service.py.
|
||||
|
||||
- CSV format defenses (gitea#23): a dirty csv_delimiter read from the DB must
|
||||
never make close_run fail with a csv.writer TypeError.
|
||||
- Formula-injection neutralisation (gitea#22): text cells starting with
|
||||
=, +, -, @, TAB or CR must be defused before they reach a CSV cell.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.setting import SystemSetting
|
||||
from src.backend.services.production_export_service import (
|
||||
_csv_format,
|
||||
neutralise_csv_cell,
|
||||
)
|
||||
|
||||
|
||||
class TestNeutraliseCsvCell:
|
||||
"""gitea#22: formula-injection prefixes must be quoted, plain text untouched."""
|
||||
|
||||
def test_equals_prefix_is_quoted(self):
|
||||
assert neutralise_csv_cell('=HYPERLINK("http://x","y")') == \
|
||||
'\'=HYPERLINK("http://x","y")'
|
||||
|
||||
def test_plus_minus_at_prefixes_are_quoted(self):
|
||||
assert neutralise_csv_cell("+1;2").startswith("'")
|
||||
assert neutralise_csv_cell("-cmd|calc").startswith("'")
|
||||
assert neutralise_csv_cell("@SUM(A1)").startswith("'")
|
||||
|
||||
def test_tab_and_cr_prefixes_are_quoted(self):
|
||||
assert neutralise_csv_cell("\t=1+1").startswith("'")
|
||||
assert neutralise_csv_cell("\r=1+1").startswith("'")
|
||||
|
||||
def test_leading_spaces_before_dangerous_prefix_are_quoted(self):
|
||||
assert neutralise_csv_cell(" =cmd").startswith("'")
|
||||
|
||||
def test_plain_text_is_untouched(self):
|
||||
assert neutralise_csv_cell("LOT-2026-001") == "LOT-2026-001"
|
||||
assert neutralise_csv_cell("") == ""
|
||||
|
||||
def test_negative_number_as_text_is_still_quoted(self):
|
||||
"""The function only sees text cells; callers must not pass numeric
|
||||
cells to it (that responsibility is the caller's, see -1.25 test in
|
||||
test_measurements.py). Given a string, a leading '-' is neutralised."""
|
||||
assert neutralise_csv_cell("-1.25").startswith("'")
|
||||
|
||||
|
||||
class TestCsvFormatDefense:
|
||||
"""`_csv_format` must never hand csv.writer a delimiter it will reject."""
|
||||
|
||||
async def test_invalid_delimiter_in_db_falls_back_to_semicolon(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
db_session.add(
|
||||
SystemSetting(
|
||||
setting_key="csv_delimiter", setting_value=";;", setting_type="string"
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
delimiter, _ = await _csv_format(db_session)
|
||||
assert delimiter == ";"
|
||||
|
||||
async def test_empty_delimiter_in_db_falls_back(self, db_session: AsyncSession):
|
||||
db_session.add(
|
||||
SystemSetting(
|
||||
setting_key="csv_delimiter", setting_value="", setting_type="string"
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
delimiter, _ = await _csv_format(db_session)
|
||||
assert delimiter == ";"
|
||||
|
||||
async def test_valid_delimiter_passes_through_unchanged(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
db_session.add(
|
||||
SystemSetting(
|
||||
setting_key="csv_delimiter", setting_value="|", setting_type="string"
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
delimiter, _ = await _csv_format(db_session)
|
||||
assert delimiter == "|"
|
||||
|
||||
async def test_no_delimiter_setting_defaults_to_comma(
|
||||
self, db_session: AsyncSession
|
||||
):
|
||||
delimiter, decimal = await _csv_format(db_session)
|
||||
assert delimiter == ","
|
||||
assert decimal == "."
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user