Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a5e98f072 | |||
| fdfcf05951 | |||
| 26884ebf4f | |||
| 8b3d3a8424 | |||
| 153fafd1b4 | |||
| cf8fab4c08 | |||
| b7cb061fee | |||
| 6a8ad4dae4 | |||
| d750cf9e0e | |||
| 2564498915 | |||
| 21f8bae5ac | |||
| c1f8a5e2ff | |||
| dee27ac8e1 | |||
| 736b853e22 | |||
| 627ae15591 | |||
| c6d4260ede | |||
| df1727be73 | |||
| 459d1c71ea | |||
| 05e3faa9d4 | |||
| dc94b2e778 | |||
| 3c55550d93 | |||
| e07c4d4eee | |||
| 0b45f1c35a | |||
| 743f246cbf | |||
| f541f4a810 | |||
| dffb11f9f2 | |||
| c8dd5eecfd | |||
| f7d6d008c7 | |||
| 837f3d1069 | |||
| f5a2affa0f | |||
| 7ef28b7a9f | |||
| 06b87a15b3 | |||
| 3b3ecceb2a | |||
| 3899d5ecd9 | |||
| f986c172c2 | |||
| e44ac3129f | |||
| 4e52095423 | |||
| 23bfc93f1c | |||
| 07e23b957d | |||
| c1c7ae8d0e | |||
| cf49695140 | |||
| c9e01a223b | |||
| 3690c35c47 | |||
| fc1902a1bb | |||
| 6648882ec1 | |||
| b5aa19170e | |||
| 2d03577165 | |||
| 27dc5e77af | |||
| 8e3316957c | |||
| 3c1d5f1323 | |||
| db5d686938 | |||
| c764cf73c6 | |||
| 86290274e5 | |||
| ee0f65ed39 | |||
| 18d0db8a0c | |||
| 3856ee335a | |||
| e640c4a36e | |||
| accf416cc9 | |||
| 84a4b5f34d | |||
| 9238f8926b | |||
| a829bf3eb0 | |||
| 7ac291b8ac | |||
| 486e1288bf | |||
| 0d004c43ff | |||
| 35f46926a9 | |||
| 1d93eded27 | |||
| 70e2072b79 | |||
| 97c49f2b5b | |||
| d483fa1b6a | |||
| 48c47e871e | |||
| cc686129c0 | |||
| b27d56d4f7 | |||
| 96dd8d6ea3 | |||
| 2b00f83a2e | |||
| e10585a071 | |||
| 8b43e7a601 | |||
| 2de226f1fd | |||
| ddb764d639 | |||
| c37533110b | |||
| 12d5b7e154 | |||
| 2b59c3ec49 | |||
| 72f0cb1f1b | |||
| c0583da478 | |||
| e8d21b78a9 | |||
| 4384c4860b | |||
| 1bb00b14f2 | |||
| e20299548f | |||
| 805f41c757 | |||
| bfe66a5710 | |||
| 77d95fdcc4 | |||
| 7b015078e9 | |||
| befdce5e6a | |||
| f739f963c7 | |||
| 31fa78bdfd | |||
| ac11cec276 | |||
| 3e99b64fc4 | |||
| 8864b2021b | |||
| 54bc384650 | |||
| 403c923188 | |||
| 65f6a4b18b | |||
| 5ca3dc14cd | |||
| a2f357b3d3 | |||
| 0ec5988581 | |||
| 1c5292d1e0 | |||
| c8caf40af9 | |||
| d40c74870e | |||
| 1576fa190b | |||
| ac76c0a395 | |||
| 302903a8a7 | |||
| b15037851b | |||
| b024724c7e | |||
| 1d5f24510c | |||
| 3f70f99a40 | |||
| 78970c69b0 | |||
| 9afe1e712b | |||
| 92f130479a | |||
| e8a8aeed66 | |||
| 20957e517d | |||
| 10261b66f0 | |||
| 7a21ec4e44 | |||
| e91ef739af | |||
| 1921bc3302 | |||
| 67392a946c | |||
| a330f2b340 | |||
| 1fee29358f | |||
| ac9bc14f5d | |||
| 0dce2299d0 | |||
| d1833ba203 | |||
| 925ed196dd | |||
| 48089bcba6 | |||
| a6d555d07a | |||
| ea9da34899 | |||
| 51f3fe32d4 | |||
| 920ab2fb73 | |||
| 7249cb8a8c |
@@ -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
|
||||
@@ -29,6 +39,11 @@ STATION_CODE=ST-DEFAULT
|
||||
# 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.
|
||||
@@ -48,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
|
||||
|
||||
+6
-13
@@ -33,19 +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/
|
||||
# Statistics files emitted when a production is closed: shop-floor evidence, not
|
||||
# source. They live on the mounted volume in production.
|
||||
uploads/statistics/
|
||||
!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
|
||||
|
||||
@@ -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
-20
@@ -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,22 +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).
|
||||
#
|
||||
# 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.
|
||||
WORKDIR /app/flask_app
|
||||
RUN npm install tailwindcss@3.4.19 && \
|
||||
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
|
||||
|
||||
|
||||
+14
-2
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13-slim
|
||||
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.
|
||||
@@ -6,7 +6,16 @@ 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:latest /uv /usr/local/bin/uv
|
||||
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 ./
|
||||
@@ -34,6 +43,9 @@ RUN test -n "$VISION_ENGINE_VERSION" || { \
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -127,16 +127,25 @@ reparto.
|
||||
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
|
||||
@@ -460,15 +469,15 @@ 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 pytest` su Python 3.11 e senza
|
||||
l'extra `vision` (un conteggio senza il suo ambiente non dice nulla, regola
|
||||
che vale anche qui): **390 pass, 1 fail, 4 skip** — 243 in `src/backend/tests/`,
|
||||
147 pass + 2 skip in `src/frontend/flask_app/tests/`, più 2 skip in
|
||||
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. Il fallimento è preesistente e indipendente da questo
|
||||
lavoro: `test_offline.py::test_no_first_party_script_calls_out`, dovuto a una
|
||||
copia locale di Fabric.js non tracciata da git (`static/js/fabric-debug.js`)
|
||||
che nei propri commenti cita pagine di documentazione esterne.
|
||||
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:
|
||||
|
||||
+28
-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,14 +32,23 @@ 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
|
||||
|
||||
@@ -50,6 +60,8 @@ services:
|
||||
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
|
||||
@@ -69,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
|
||||
|
||||
@@ -87,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
|
||||
|
||||
|
||||
+34
-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,22 +32,33 @@ 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
|
||||
@@ -59,6 +71,11 @@ services:
|
||||
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
|
||||
@@ -78,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
|
||||
|
||||
+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
|
||||
|
||||
+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)
|
||||
|
||||
+5
-1
@@ -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 = [
|
||||
@@ -63,6 +63,8 @@ vision = [
|
||||
"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'",
|
||||
]
|
||||
|
||||
@@ -101,6 +103,8 @@ 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"
|
||||
|
||||
+90
-11
@@ -21,16 +21,28 @@ are already in place:
|
||||
|
||||
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.
|
||||
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, select
|
||||
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
|
||||
@@ -86,6 +98,43 @@ async def _find_recipes(session, 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:
|
||||
@@ -176,25 +225,33 @@ async def _ensure_supervisor(session, username, password):
|
||||
|
||||
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
|
||||
return existing, False, password
|
||||
|
||||
password_used = password or secrets.token_urlsafe(12)
|
||||
user = await auth_service.create_user(
|
||||
session,
|
||||
username=username,
|
||||
password=password,
|
||||
password=password_used,
|
||||
display_name="Capo Turno (collaudo)",
|
||||
roles=["Supervisor", "MeasurementTec"],
|
||||
)
|
||||
await session.flush()
|
||||
return user, True
|
||||
return user, True, password_used
|
||||
|
||||
|
||||
async def seed(station_code: str, replace: bool, supervisor_password: str) -> int:
|
||||
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
|
||||
@@ -217,6 +274,14 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
|
||||
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)}")
|
||||
|
||||
@@ -286,7 +351,7 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
|
||||
)
|
||||
await _assign(session, station, recipe_b, owner.id)
|
||||
|
||||
supervisor, created = await _ensure_supervisor(
|
||||
supervisor, created, password_used = await _ensure_supervisor(
|
||||
session, "capoturno", supervisor_password,
|
||||
)
|
||||
|
||||
@@ -296,7 +361,7 @@ async def seed(station_code: str, replace: bool, supervisor_password: str) -> in
|
||||
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} / {supervisor_password}")
|
||||
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)")
|
||||
@@ -309,11 +374,25 @@ def main() -> int:
|
||||
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("--supervisor-password", default="Collaudo2026!",
|
||||
help="password del capoturno, se va creato")
|
||||
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)
|
||||
seed(args.station, args.replace, args.supervisor_password, args.si)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,34 @@ 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"
|
||||
# #59: il login con PIN e' lo stesso attacco (4 cifre: ancora piu' facile da indovinare)
|
||||
LOGIN_PIN_PATH = "/api/auth/login-pin"
|
||||
WINDOW_SECONDS = 60
|
||||
|
||||
# Paths that carry a supervisor's (capoturno) credentials in the request
|
||||
# body. Matched by pattern, not equality, because the id in the middle
|
||||
# varies per request.
|
||||
_SUPERVISOR_CREDENTIAL_PATTERNS = (
|
||||
re.compile(r"^/api/measurements/\d+/authorise$"),
|
||||
re.compile(r"^/api/production-runs/\d+/(?:pause|resume|close)$"),
|
||||
)
|
||||
|
||||
def __init__(self, app) -> None:
|
||||
super().__init__(app)
|
||||
# {ip: [timestamp, ...]} per bucket
|
||||
@@ -34,23 +56,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 in (cls.LOGIN_PATH, cls.LOGIN_PIN_PATH):
|
||||
return True
|
||||
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
|
||||
|
||||
@staticmethod
|
||||
def _effective_limit(configured: int) -> int:
|
||||
"""Divide a configured per-minute limit across worker processes.
|
||||
|
||||
Rounds up and never returns less than 1, so a low configured limit
|
||||
with high concurrency still allows at least one request per worker
|
||||
per window rather than locking everyone out.
|
||||
"""
|
||||
concurrency = max(settings.web_concurrency, 1)
|
||||
return max(1, -(-configured // concurrency))
|
||||
|
||||
def _clean_window(self, timestamps: list[float], now: float) -> list[float]:
|
||||
"""Remove timestamps outside the current sliding window."""
|
||||
cutoff = now - self.WINDOW_SECONDS
|
||||
@@ -97,10 +160,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 +176,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(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Authentication router - login, logout, profile."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
@@ -8,10 +9,17 @@ from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
PinLoginRequest,
|
||||
PinUserItem,
|
||||
UserProfileUpdate,
|
||||
UserResponse,
|
||||
)
|
||||
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
|
||||
from src.backend.services.auth_service import (
|
||||
authenticate_pin,
|
||||
authenticate_user,
|
||||
login_user,
|
||||
logout_user,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -32,13 +40,47 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pin-users", response_model=list[PinUserItem])
|
||||
async def pin_users(db: AsyncSession = Depends(get_db)):
|
||||
"""Utenti che possono entrare con PIN (#59): attivi, non admin, con PIN.
|
||||
|
||||
Pubblico per costruzione (serve prima del login): espone solo id e nome.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.active == True, # noqa: E712
|
||||
User.is_admin == False, # noqa: E712
|
||||
User.pin_hash.is_not(None),
|
||||
)
|
||||
)
|
||||
items = [
|
||||
PinUserItem(id=u.id, display_name=u.display_name or u.username)
|
||||
for u in result.scalars().all()
|
||||
]
|
||||
items.sort(key=lambda i: i.display_name.casefold())
|
||||
return items
|
||||
|
||||
|
||||
@router.post("/login-pin", response_model=LoginResponse)
|
||||
async def login_pin(data: PinLoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Login con user_id e PIN a 4 cifre (#59); stessa risposta del login."""
|
||||
user = await authenticate_pin(db, data.user_id, data.pin)
|
||||
api_key = await login_user(db, user)
|
||||
return LoginResponse(user=UserResponse.model_validate(user), api_key=api_key)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
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,93 @@ ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
ALLOWED_PDF_TYPE = "application/pdf"
|
||||
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | {ALLOWED_PDF_TYPE}
|
||||
|
||||
# Magic-byte signatures: the client-declared Content-Type is only what the
|
||||
# browser claimed and cannot be trusted (#36 point 2 — a file named "x.html"
|
||||
# declared image/png was stored and then served by extension as text/html,
|
||||
# which a browser executes). SVG is intentionally not recognised: it is not
|
||||
# in ALLOWED_TYPES and can itself carry script content.
|
||||
_MAGIC_SIGNATURES: list[tuple[bytes, str, str]] = [
|
||||
# (signature, mime type, canonical extension)
|
||||
(b"\x89PNG\r\n\x1a\n", "image/png", ".png"),
|
||||
(b"\xff\xd8\xff", "image/jpeg", ".jpg"),
|
||||
(b"GIF87a", "image/gif", ".gif"),
|
||||
(b"GIF89a", "image/gif", ".gif"),
|
||||
(b"%PDF-", "application/pdf", ".pdf"),
|
||||
]
|
||||
|
||||
# Read uploads in chunks so a declared-huge body is rejected as soon as it
|
||||
# crosses the limit, instead of being buffered into memory in full first.
|
||||
_UPLOAD_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
|
||||
def detect_file_type(content: bytes) -> tuple[str, str] | None:
|
||||
"""Identify a file's real type from its magic bytes.
|
||||
|
||||
Returns (mime_type, canonical_extension), or None when the content does
|
||||
not match any type this system serves.
|
||||
"""
|
||||
for signature, mime, ext in _MAGIC_SIGNATURES:
|
||||
if content.startswith(signature):
|
||||
return mime, ext
|
||||
# WebP: "RIFF" + 4-byte little-endian size + "WEBP"; the size varies.
|
||||
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
|
||||
return "image/webp", ".webp"
|
||||
return None
|
||||
|
||||
# Top-level folders nothing may DELETE through this router: statistics/reports are
|
||||
# audit records, the logo is only ever replaced via POST /api/settings/logo.
|
||||
# task_results/: allegati degli esiti dei task (#55), record di audit.
|
||||
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/", "task_results/")
|
||||
|
||||
# GET under these needs a role that is meant to see production data, not just
|
||||
# any authenticated user (a Maker or MeasurementTec has no business reading
|
||||
# every production's statistics/report file by guessing its name).
|
||||
_RESTRICTED_GET_PREFIXES = ("statistics/", "reports/")
|
||||
|
||||
|
||||
def _can_read_restricted(user: User) -> bool:
|
||||
return user.is_admin or user.has_role("Metrologist") or user.has_role("Supervisor")
|
||||
|
||||
|
||||
async def _is_recipe_file_protected(db: AsyncSession, relative_path: str) -> bool:
|
||||
"""True when `relative_path` (uploads-relative, forward slashes) must not be
|
||||
deleted: it is a recipe's cover image, or a task/subtask file belonging to a
|
||||
version that is either not the current one (kept for history) or already has
|
||||
measurements against it (the audit trail needs it to make sense of them).
|
||||
|
||||
A path not referenced anywhere is safe to delete (orphaned upload).
|
||||
"""
|
||||
recipe_hit = (await db.execute(
|
||||
select(Recipe.id).where(Recipe.image_path == relative_path)
|
||||
)).scalar_one_or_none()
|
||||
if recipe_hit is not None:
|
||||
return True
|
||||
|
||||
version_id = (await db.execute(
|
||||
select(RecipeTask.version_id).where(RecipeTask.file_path == relative_path)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if version_id is None:
|
||||
version_id = (await db.execute(
|
||||
select(RecipeTask.version_id)
|
||||
.join(RecipeSubtask, RecipeSubtask.task_id == RecipeTask.id)
|
||||
.where(RecipeSubtask.image_path == relative_path)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if version_id is None:
|
||||
return False
|
||||
|
||||
version = (await db.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.id == version_id)
|
||||
)).scalar_one_or_none()
|
||||
if version is None or not version.is_current:
|
||||
return True
|
||||
|
||||
has_measurements = (await db.execute(
|
||||
select(Measurement.id).where(Measurement.version_id == version_id).limit(1)
|
||||
)).scalar_one_or_none() is not None
|
||||
return has_measurements
|
||||
|
||||
|
||||
def validate_file_type(content_type: str) -> bool:
|
||||
"""Validate if file type is allowed."""
|
||||
@@ -119,25 +212,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 +251,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 +269,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 +286,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 +298,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 +314,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,6 +21,7 @@ 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,
|
||||
@@ -28,6 +31,7 @@ from src.backend.models.api.measurement import (
|
||||
)
|
||||
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"])
|
||||
|
||||
@@ -96,6 +100,24 @@ async def get_task_progress(
|
||||
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,
|
||||
@@ -125,6 +147,7 @@ async def authorise_measurement(
|
||||
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||
)
|
||||
measurement = await measurement_service.authorise_measurement(
|
||||
db, measurement, supervisor,
|
||||
@@ -173,6 +196,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 = []
|
||||
@@ -198,6 +222,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
|
||||
|
||||
|
||||
@@ -209,9 +235,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),
|
||||
@@ -227,7 +254,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
|
||||
@@ -266,15 +293,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",
|
||||
@@ -342,10 +372,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,
|
||||
])
|
||||
|
||||
@@ -355,6 +385,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"
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, Query, status
|
||||
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
|
||||
@@ -20,15 +20,32 @@ from src.backend.models.api.production import (
|
||||
)
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import auth_service, production_service
|
||||
from src.backend.models.api.monitor import MonitorRun
|
||||
from src.backend.services import auth_service, monitor_service, production_service
|
||||
|
||||
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||
|
||||
_DERIVED = {
|
||||
"seconds_to_next_measurement", "overdue", "server_time", "measurement_task_ids",
|
||||
"close_task_ids",
|
||||
}
|
||||
|
||||
|
||||
async def _require_run_operator(user: User = Depends(get_current_user)) -> User:
|
||||
"""Open/advance/pause/resume/close a run: MeasurementTec, Supervisor, or admin.
|
||||
|
||||
A Maker with no floor role could otherwise start and drive a production the
|
||||
same way an operator does (gitea#24) - reading a run stays open to any
|
||||
authenticated user, only the lifecycle actions are gated.
|
||||
"""
|
||||
if user.is_admin or user.has_role("MeasurementTec") or user.has_role("Supervisor"):
|
||||
return user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="MeasurementTec or Supervisor role required",
|
||||
)
|
||||
|
||||
|
||||
async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunResponse:
|
||||
"""The stored run plus everything the client cannot work out on its own."""
|
||||
return ProductionRunResponse(
|
||||
@@ -37,13 +54,15 @@ async def _as_response(db: AsyncSession, run: ProductionRun) -> ProductionRunRes
|
||||
measurement_task_ids=await production_service.measurement_task_ids(
|
||||
db, run.version_id,
|
||||
),
|
||||
# #53: task da compilare alla fine produzione
|
||||
close_task_ids=await production_service.close_task_ids(db, run.version_id),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ProductionRunResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def open_production_run(
|
||||
data: ProductionRunCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Start a production at a station."""
|
||||
@@ -66,6 +85,27 @@ async def get_current_production_run(
|
||||
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,
|
||||
@@ -85,7 +125,7 @@ async def get_production_run(
|
||||
async def complete_measurement_cycle(
|
||||
run_id: int,
|
||||
payload: CycleCompletePayload | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Record a finished measurement task; the last one restarts the interval."""
|
||||
@@ -102,7 +142,7 @@ async def complete_measurement_cycle(
|
||||
async def remeasure_in_cycle(
|
||||
run_id: int,
|
||||
payload: RemeasurePayload | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Turn the piece over and measure again, without closing the cycle."""
|
||||
@@ -119,13 +159,14 @@ async def remeasure_in_cycle(
|
||||
async def pause_production_run(
|
||||
run_id: int,
|
||||
action: SupervisorAction,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fermo linea - requires a supervisor."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||
)
|
||||
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||
return await _as_response(db, run)
|
||||
@@ -135,13 +176,14 @@ async def pause_production_run(
|
||||
async def resume_production_run(
|
||||
run_id: int,
|
||||
action: SupervisorAction,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Restart a stopped line - requires a supervisor."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||
)
|
||||
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||
return await _as_response(db, run)
|
||||
@@ -151,13 +193,14 @@ async def resume_production_run(
|
||||
async def close_production_run(
|
||||
run_id: int,
|
||||
action: SupervisorAction,
|
||||
user: User = Depends(get_current_user),
|
||||
user: User = Depends(_require_run_operator),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Fine produzione - requires a supervisor. Stops the timer for good."""
|
||||
run = await production_service.get_run(db, run_id)
|
||||
supervisor = await auth_service.authorise_supervisor(
|
||||
db, action.supervisor_username, action.supervisor_password,
|
||||
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||
)
|
||||
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||
return await _as_response(db, run)
|
||||
|
||||
@@ -9,6 +9,7 @@ from src.backend.api.middleware.api_key import get_current_user, require_maker,
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.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"}
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Task results router: l'esito di un task 'check' (OK/NOK) o 'note' (#54 #55).
|
||||
|
||||
Un 'check' registra ok o nok (nok vuole la nota); un 'note' registra 'done' con i
|
||||
valori dei campi extra e gli allegati. I task di misura non passano di qui: le
|
||||
loro letture sono Measurement.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.api.middleware.api_key import require_measurement_tec
|
||||
from src.backend.api.routers.files import (
|
||||
_UPLOAD_CHUNK_SIZE, detect_file_type, sanitize_filename,
|
||||
)
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.models.api.task_result import TaskResultCreate, TaskResultResponse
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
from src.backend.models.orm.task_result import TaskResult
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/task-results", tags=["task-results"])
|
||||
|
||||
|
||||
def _unprocessable(detail: str) -> HTTPException:
|
||||
# 422 con detail testuale: l'api_client del frontend legge solo `detail`
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
|
||||
|
||||
|
||||
# Radice degli allegati degli esiti: sotto upload_path, mai cancellabile (audit).
|
||||
ATTACHMENT_DIR = "task_results"
|
||||
|
||||
|
||||
def _safe_relative_path(path: str) -> bool:
|
||||
"""Allegato: relativo, dentro task_results/, senza '..', unita' o backslash."""
|
||||
if path.startswith("/") or "\\" in path or ":" in path:
|
||||
return False
|
||||
parts = path.split("/")
|
||||
return ".." not in parts and parts[0] == ATTACHMENT_DIR and len(parts) > 1
|
||||
|
||||
|
||||
@router.post("/attachments", status_code=status.HTTP_201_CREATED)
|
||||
async def upload_attachment(
|
||||
file: UploadFile = File(...),
|
||||
_user: User = Depends(require_measurement_tec),
|
||||
):
|
||||
"""Carica l'allegato di un task 'note' (l'operatore non ha accesso a /api/files/upload).
|
||||
|
||||
Stessi controlli di upload_file: tipo dai magic bytes, limite di dimensione
|
||||
con lettura a blocchi, nome ripulito. Salva sotto task_results/<AAAA>/<MM>/.
|
||||
"""
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
buffer = bytearray()
|
||||
while True:
|
||||
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
)
|
||||
content = bytes(buffer)
|
||||
|
||||
detected = detect_file_type(content)
|
||||
if detected is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File type not allowed. Must be a recognised image or PDF.",
|
||||
)
|
||||
_mime, ext = detected
|
||||
|
||||
# nome originale ripulito (per mostrarlo); sul disco solo uuid + estensione rilevata
|
||||
name = sanitize_filename(file.filename or "")
|
||||
now = datetime.now(timezone.utc)
|
||||
storage_dir = settings.upload_path / ATTACHMENT_DIR / f"{now:%Y}" / f"{now:%m}"
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = storage_dir / f"{uuid.uuid4().hex}{ext}"
|
||||
target.write_bytes(content)
|
||||
|
||||
return {
|
||||
"file_path": str(target.relative_to(settings.upload_path)).replace("\\", "/"),
|
||||
"name": name,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=TaskResultResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task_result(
|
||||
data: TaskResultCreate,
|
||||
user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Registra l'esito di un task 'check' o 'note'."""
|
||||
task = (await db.execute(
|
||||
select(RecipeTask).where(RecipeTask.id == data.task_id)
|
||||
)).scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||
|
||||
note = data.note.strip() if data.note else None
|
||||
note = note or None
|
||||
values: Optional[dict] = None
|
||||
attachments: Optional[list] = None
|
||||
|
||||
if task.task_type == "check":
|
||||
if data.outcome not in ("ok", "nok"):
|
||||
raise _unprocessable("Un controllo si registra come ok o nok")
|
||||
if data.outcome == "nok" and not note:
|
||||
raise _unprocessable("Per un NOK serve la nota")
|
||||
elif task.task_type == "note":
|
||||
if data.outcome != "done":
|
||||
raise _unprocessable("Una nota si registra come done")
|
||||
fields = {f["key"]: f for f in (task.fields or [])}
|
||||
values = {}
|
||||
for key, value in (data.values or {}).items():
|
||||
if key not in fields:
|
||||
raise _unprocessable(f"Campo '{key}' non previsto dal task")
|
||||
if fields[key]["type"] == "number" and value.strip() != "":
|
||||
try:
|
||||
float(value.replace(",", "."))
|
||||
except ValueError:
|
||||
raise _unprocessable(f"Il campo '{fields[key]['label']}' deve essere un numero")
|
||||
values[key] = value
|
||||
attachments = []
|
||||
for att in data.attachments or []:
|
||||
if not _safe_relative_path(att.path):
|
||||
raise _unprocessable(
|
||||
f"Percorso allegato non valido (deve stare in {ATTACHMENT_DIR}/): {att.path}"
|
||||
)
|
||||
if att.key is not None and att.key not in fields:
|
||||
raise _unprocessable(f"Campo allegato '{att.key}' non previsto dal task")
|
||||
attachments.append(att.model_dump())
|
||||
values = values or None
|
||||
attachments = attachments or None
|
||||
else:
|
||||
raise _unprocessable(
|
||||
f"I task di tipo '{task.task_type}' non registrano un esito"
|
||||
)
|
||||
|
||||
if data.production_run_id is not None:
|
||||
run = (await db.execute(
|
||||
select(ProductionRun).where(ProductionRun.id == data.production_run_id)
|
||||
)).scalar_one_or_none()
|
||||
if run is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found"
|
||||
)
|
||||
if run.status == "closed":
|
||||
raise _unprocessable("La produzione e' gia' chiusa")
|
||||
if run.version_id != task.version_id:
|
||||
raise _unprocessable("Il task non appartiene alla versione di questa produzione")
|
||||
|
||||
result = TaskResult(
|
||||
task_id=task.id,
|
||||
version_id=task.version_id, # presa dal task, non dal client
|
||||
production_run_id=data.production_run_id,
|
||||
user_id=user.id,
|
||||
outcome=data.outcome,
|
||||
note=note,
|
||||
values=values,
|
||||
attachments=attachments,
|
||||
)
|
||||
db.add(result)
|
||||
await db.flush()
|
||||
await db.refresh(result) # created_at e' un server_default
|
||||
return TaskResultResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskResultResponse])
|
||||
async def list_task_results(
|
||||
production_run_id: Optional[int] = Query(None, gt=0),
|
||||
task_id: Optional[int] = Query(None, gt=0),
|
||||
_user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Esiti registrati, filtrabili per produzione e/o task; il piu' recente prima."""
|
||||
stmt = select(TaskResult)
|
||||
if production_run_id is not None:
|
||||
stmt = stmt.where(TaskResult.production_run_id == production_run_id)
|
||||
if task_id is not None:
|
||||
stmt = stmt.where(TaskResult.task_id == task_id)
|
||||
rows = (await db.execute(
|
||||
stmt.order_by(TaskResult.created_at.desc(), TaskResult.id.desc())
|
||||
)).scalars().all()
|
||||
return [TaskResultResponse.model_validate(r) for r in rows]
|
||||
@@ -17,8 +17,13 @@ from src.backend.models.api.task import (
|
||||
TaskReorderRequest,
|
||||
TaskResponse,
|
||||
TaskUpdate,
|
||||
check_task_loop_options,
|
||||
default_in_loop,
|
||||
normalize_note_fields,
|
||||
validate_tolerance_order,
|
||||
)
|
||||
from src.backend.services import recipe_service
|
||||
from src.backend.models.orm.vision import VisionReferenceImage
|
||||
from src.backend.services import recipe_service, vision_service
|
||||
|
||||
router = APIRouter(tags=["tasks"])
|
||||
|
||||
@@ -99,6 +104,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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -142,6 +205,13 @@ async def create_task(
|
||||
This creates a NEW version via copy-on-write, then appends the task to
|
||||
the new version. Requires Maker role.
|
||||
"""
|
||||
# #53 #55: validazione di in_loop/at_close/fields prima di toccare il db
|
||||
try:
|
||||
fields = normalize_note_fields(data.fields)
|
||||
check_task_loop_options(data.task_type, data.at_close, fields)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
# Ensure recipe exists and is active
|
||||
result = await db.execute(select(Recipe).where(Recipe.id == recipe_id))
|
||||
recipe = result.scalar_one_or_none()
|
||||
@@ -180,6 +250,10 @@ async def create_task(
|
||||
file_type=data.file_type,
|
||||
annotations_json=data.annotations_json,
|
||||
vision_json=data.vision_json,
|
||||
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
|
||||
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
|
||||
at_close=data.at_close,
|
||||
fields=fields,
|
||||
)
|
||||
db.add(new_task)
|
||||
await db.flush()
|
||||
@@ -264,10 +338,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)
|
||||
|
||||
@@ -275,6 +355,28 @@ async def update_task(
|
||||
if not update_data:
|
||||
return TaskResponse.model_validate(task)
|
||||
|
||||
# #53 #55: i tre campi nuovi si validano sulla combinazione finale (valori
|
||||
# nuovi, o quelli gia' sul task per cio' che il client non manda)
|
||||
if "fields" in update_data:
|
||||
try:
|
||||
update_data["fields"] = normalize_note_fields(update_data["fields"])
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
for flag in ("in_loop", "at_close"):
|
||||
if flag in update_data and update_data[flag] is None:
|
||||
del update_data[flag] # i booleani non sono nullable: null = non toccare
|
||||
final_type = update_data.get("task_type") or task.task_type
|
||||
try:
|
||||
check_task_loop_options(
|
||||
final_type,
|
||||
update_data.get("at_close", task.at_close),
|
||||
update_data.get("fields", task.fields),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
task = await _task_for_write(db, task, user)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(task, field, value)
|
||||
|
||||
@@ -286,18 +388,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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -312,16 +433,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:
|
||||
@@ -331,7 +455,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,
|
||||
@@ -354,12 +478,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)
|
||||
|
||||
@@ -371,6 +496,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)
|
||||
|
||||
@@ -382,12 +525,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)
|
||||
|
||||
@@ -395,5 +539,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,19 @@
|
||||
"""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.models.api.user import PinSet, UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||
from src.backend.services.auth_service import (
|
||||
create_user,
|
||||
hash_password,
|
||||
regenerate_api_key,
|
||||
revoke_all_keys,
|
||||
set_user_pin,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
@@ -78,6 +84,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 +107,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,16 +142,45 @@ 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}"}
|
||||
|
||||
|
||||
@router.put("/{user_id}/pin", status_code=status.HTTP_200_OK)
|
||||
async def change_user_pin(
|
||||
user_id: int,
|
||||
data: PinSet,
|
||||
admin: User = Depends(require_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Imposta o toglie (pin null) il PIN di un utente (admin, #59/#51).
|
||||
|
||||
422 se l'utente e' admin. Il PIN non si restituisce mai.
|
||||
"""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
if user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Admin users cannot have a PIN",
|
||||
)
|
||||
await set_user_pin(db, user, data.pin)
|
||||
return {"message": f"PIN {'set' if data.pin else 'removed'} for user {user.username}"}
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def deactivate_user(
|
||||
user_id: int,
|
||||
@@ -127,7 +198,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 +213,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}"}
|
||||
|
||||
@@ -4,15 +4,26 @@ 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, validate_file_size
|
||||
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
|
||||
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
|
||||
@@ -28,6 +39,29 @@ _IMAGE_EXTENSIONS = {
|
||||
"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."""
|
||||
@@ -60,22 +94,45 @@ async def _read_image(image: UploadFile) -> bytes:
|
||||
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),
|
||||
serial_number: str | None = Form(None),
|
||||
production_run_id: int | None = Form(None),
|
||||
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 = await vision_service.execute_task(
|
||||
result, saved, report = await vision_service.execute_task(
|
||||
db,
|
||||
task,
|
||||
await _read_image(image),
|
||||
@@ -84,6 +141,7 @@ async def execute(
|
||||
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()
|
||||
|
||||
@@ -96,11 +154,116 @@ async def execute(
|
||||
)
|
||||
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: both endpoints below gate on `require_maker`, not the
|
||||
# measuring: every endpoint below gates on `require_maker`, not the
|
||||
# `require_measurement_tec` that `execute` above uses.
|
||||
|
||||
|
||||
@@ -118,10 +281,41 @@ async def add_reference_image(
|
||||
db, task.id, content, note=note,
|
||||
extension=_IMAGE_EXTENSIONS[image.content_type],
|
||||
)
|
||||
await db.commit()
|
||||
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(...),
|
||||
|
||||
+59
-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,6 +64,10 @@ 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
|
||||
@@ -72,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()
|
||||
|
||||
@@ -23,6 +23,7 @@ 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
|
||||
from src.backend.api.routers.task_results import router as task_results_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -77,6 +78,7 @@ app.include_router(setup_router)
|
||||
app.include_router(stations_router)
|
||||
app.include_router(production_router)
|
||||
app.include_router(vision_router)
|
||||
app.include_router(task_results_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -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,76 @@
|
||||
"""vision results and reference images cascade with their task
|
||||
|
||||
Both `vision_results.task_id` and `vision_reference_images.task_id` were
|
||||
created (012, 013) with a plain, unnamed foreign key - no ON DELETE clause,
|
||||
so deleting a task with any camera history or reference image attached fails
|
||||
on a strict FK check (MySQL) instead of taking the vision rows with it, the
|
||||
way `production_events.run_id` already does for a closed production run.
|
||||
|
||||
The original constraint has no name to drop by (SQLAlchemy leaves inline
|
||||
`ForeignKey(...)` unnamed unless told otherwise, and MySQL then assigns its
|
||||
own `<table>_ibfk_N`, which is not portable to reflect on). `sa.inspect`
|
||||
looks it up at migration time instead of guessing it - the same way on
|
||||
MySQL and on SQLite (batch mode), where an anonymous FK reflects with
|
||||
`name=None` and there is nothing to drop before adding the new one.
|
||||
|
||||
Reference images on disk are not touched here - deleting a task's row does
|
||||
not, on its own, know to also delete the file next to it; that gap is
|
||||
declared open in issue #38 point 6's write-up (`vision_service` has no
|
||||
single point today that intercepts a task deletion).
|
||||
|
||||
Revision ID: 014_vision_fk_ondelete
|
||||
Revises: 013_reference_images
|
||||
Create Date: 2026-09-21
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '014_vision_fk_ondelete'
|
||||
down_revision: Union[str, None] = '013_reference_images'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_NEW_FK_NAMES = {
|
||||
'vision_results': 'fk_vision_results_task_id',
|
||||
'vision_reference_images': 'fk_vision_reference_images_task_id',
|
||||
}
|
||||
|
||||
|
||||
def _existing_task_id_fk_name(bind, table_name: str) -> Union[str, None]:
|
||||
"""The name reflection gives the pre-existing `task_id` FK, if any.
|
||||
|
||||
None on SQLite for a constraint that was never named - batch mode then
|
||||
has nothing to drop, and just adds the new named one alongside it.
|
||||
"""
|
||||
inspector = sa.inspect(bind)
|
||||
for fk in inspector.get_foreign_keys(table_name):
|
||||
if fk.get("constrained_columns") == ["task_id"]:
|
||||
return fk.get("name")
|
||||
return None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
for table_name, new_name in _NEW_FK_NAMES.items():
|
||||
existing_name = _existing_task_id_fk_name(bind, table_name)
|
||||
with op.batch_alter_table(table_name) as batch:
|
||||
if existing_name:
|
||||
batch.drop_constraint(existing_name, type_='foreignkey')
|
||||
batch.create_foreign_key(
|
||||
new_name, 'recipe_tasks', ['task_id'], ['id'],
|
||||
ondelete='CASCADE',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Reverse order: reference_images before results, mirroring upgrade().
|
||||
for table_name, name in reversed(list(_NEW_FK_NAMES.items())):
|
||||
with op.batch_alter_table(table_name) as batch:
|
||||
batch.drop_constraint(name, type_='foreignkey')
|
||||
batch.create_foreign_key(
|
||||
name, 'recipe_tasks', ['task_id'], ['id'],
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""API keys as SHA-256 hashes, one per session (#41)
|
||||
|
||||
Replaces the plaintext `users.api_key` column with an `api_keys` table: one
|
||||
row per login, holding only the SHA-256 hex digest, an expiry and the last
|
||||
use. Existing keys are invalidated on purpose, not migrated - the plaintext
|
||||
is exactly what must stop existing, so everybody logs in once again.
|
||||
|
||||
Revision ID: 015_api_key_hash
|
||||
Revises: 014_vision_fk_ondelete
|
||||
Create Date: 2026-10-06
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '015_api_key_hash'
|
||||
down_revision: Union[str, None] = '014_vision_fk_ondelete'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||
),
|
||||
)
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.drop_index('ix_users_api_key')
|
||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||
batch.drop_column('api_key')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.add_column(sa.Column('api_key', sa.String(64), nullable=True))
|
||||
batch.create_unique_constraint('uq_users_api_key', ['api_key'])
|
||||
batch.create_index('ix_users_api_key', ['api_key'])
|
||||
|
||||
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
|
||||
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
|
||||
op.drop_table('api_keys')
|
||||
@@ -0,0 +1,30 @@
|
||||
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
|
||||
|
||||
Percorso relativo sotto upload_path della cartella immutabile che contiene
|
||||
recipe.json e i file di meta (DXF, modelli .npz, ...).
|
||||
|
||||
Revision ID: 016_vision_recipe_dir
|
||||
Revises: 015_api_key_hash
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '016_vision_recipe_dir'
|
||||
down_revision: Union[str, None] = '015_api_key_hash'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.drop_column('vision_recipe_dir')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
|
||||
|
||||
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
|
||||
|
||||
Revision ID: 017_user_pin
|
||||
Revises: 016_vision_recipe_dir
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '017_user_pin'
|
||||
down_revision: Union[str, None] = '016_vision_recipe_dir'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
|
||||
batch.add_column(sa.Column(
|
||||
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.drop_column('pin_locked_until')
|
||||
batch.drop_column('pin_failed_count')
|
||||
batch.drop_column('pin_hash')
|
||||
@@ -0,0 +1,108 @@
|
||||
"""task: in_loop / at_close / fields, tipo 'check', tabella task_results (#53 #54 #55)
|
||||
|
||||
in_loop: il task fa parte del ciclo di produzione (prima: implicito dal tipo).
|
||||
at_close: il task 'note' si compila alla fine produzione.
|
||||
fields: campi extra del task 'note' (JSON).
|
||||
task_results: esito registrato da un task 'check' (OK/NOK) o 'note' (done).
|
||||
|
||||
Revision ID: 018_task_loop_results
|
||||
Revises: 017_user_pin
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '018_task_loop_results'
|
||||
down_revision: Union[str, None] = '017_user_pin'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
OLD_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||
NEW_TYPES = OLD_TYPES + ('check',)
|
||||
# stessi tipi di MEASURING_TASK_TYPES: fino a oggi erano l'unico ciclo possibile
|
||||
LOOP_TYPES = ('measure', 'camera_measure', 'dxf_compare')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.add_column(sa.Column(
|
||||
'in_loop', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch.add_column(sa.Column(
|
||||
'at_close', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch.add_column(sa.Column('fields', sa.JSON(), nullable=True))
|
||||
# enum allargato come in 011: 'check' = Controllo OK/NOK
|
||||
batch.alter_column(
|
||||
'task_type',
|
||||
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||
existing_nullable=False,
|
||||
existing_server_default='note',
|
||||
)
|
||||
|
||||
# i task di misura erano gia' nel ciclo: lo restano
|
||||
recipe_tasks = sa.table(
|
||||
'recipe_tasks', sa.column('task_type', sa.String), sa.column('in_loop', sa.Boolean))
|
||||
op.execute(
|
||||
recipe_tasks.update()
|
||||
.where(recipe_tasks.c.task_type.in_(LOOP_TYPES))
|
||||
.values(in_loop=True)
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'task_results',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('task_id', sa.Integer(),
|
||||
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('version_id', sa.Integer(),
|
||||
sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||
sa.Column('production_run_id', sa.Integer(),
|
||||
sa.ForeignKey('production_runs.id', ondelete='SET NULL'), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('outcome', sa.Enum('ok', 'nok', 'done', name='task_outcome_enum'),
|
||||
nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=True),
|
||||
sa.Column('values', sa.JSON(), nullable=True),
|
||||
sa.Column('attachments', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
op.create_index('ix_task_results_task_id', 'task_results', ['task_id'])
|
||||
op.create_index('ix_task_results_version_id', 'task_results', ['version_id'])
|
||||
op.create_index(
|
||||
'ix_task_results_production_run_id', 'task_results', ['production_run_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# il tipo 'check' non esiste prima di 018: se un task lo usa, tornare indietro
|
||||
# lo lascerebbe con un valore fuori enum
|
||||
bind = op.get_bind()
|
||||
used = bind.execute(
|
||||
sa.text("SELECT COUNT(*) FROM recipe_tasks WHERE task_type = 'check'")
|
||||
).scalar()
|
||||
if used:
|
||||
raise RuntimeError(
|
||||
f"Downgrade 018 impossibile: {used} task usano il tipo 'check'. "
|
||||
"Cambiare il tipo o eliminare quei task prima di tornare indietro."
|
||||
)
|
||||
|
||||
op.drop_index('ix_task_results_production_run_id', table_name='task_results')
|
||||
op.drop_index('ix_task_results_version_id', table_name='task_results')
|
||||
op.drop_index('ix_task_results_task_id', table_name='task_results')
|
||||
op.drop_table('task_results')
|
||||
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.alter_column(
|
||||
'task_type',
|
||||
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||
existing_nullable=False,
|
||||
existing_server_default='note',
|
||||
)
|
||||
batch.drop_column('fields')
|
||||
batch.drop_column('at_close')
|
||||
batch.drop_column('in_loop')
|
||||
@@ -1,14 +1,21 @@
|
||||
"""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
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
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)
|
||||
@@ -47,8 +54,8 @@ class MeasurementResponse(BaseModel):
|
||||
# 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[datetime] = None
|
||||
measured_at: datetime
|
||||
authorised_at: Optional[UtcDateTime] = None
|
||||
measured_at: UtcDateTime
|
||||
synced_to_csv: bool
|
||||
|
||||
|
||||
@@ -64,8 +71,21 @@ class MeasurementListResponse(BaseModel):
|
||||
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)
|
||||
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||
supervisor_username: Optional[str] = Field(None, min_length=1)
|
||||
supervisor_password: Optional[str] = Field(None, min_length=1)
|
||||
supervisor_user_id: Optional[int] = Field(None, gt=0)
|
||||
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_credential_pair(self):
|
||||
by_password = self.supervisor_username and self.supervisor_password
|
||||
by_pin = self.supervisor_user_id and self.supervisor_pin
|
||||
if not (by_password or by_pin):
|
||||
raise ValueError(
|
||||
"supervisor credentials required: username+password or user_id+pin"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class TaskProgressResponse(BaseModel):
|
||||
@@ -84,6 +104,21 @@ class TaskProgressListResponse(BaseModel):
|
||||
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
|
||||
@@ -92,8 +127,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
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Pydantic schemas for production runs and their events."""
|
||||
from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class ProductionRunCreate(BaseModel):
|
||||
@@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel):
|
||||
supervisor_id: Optional[int]
|
||||
task_id: Optional[int] = None
|
||||
note: Optional[str]
|
||||
created_at: datetime
|
||||
created_at: UtcDateTime
|
||||
|
||||
|
||||
class ProductionRunResponse(BaseModel):
|
||||
@@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel):
|
||||
serial_number: Optional[str]
|
||||
status: str
|
||||
measurement_interval_minutes: Optional[int]
|
||||
next_measurement_at: Optional[datetime]
|
||||
next_measurement_at: Optional[UtcDateTime]
|
||||
cycle_count: int
|
||||
started_at: datetime
|
||||
paused_at: Optional[datetime]
|
||||
closed_at: Optional[datetime]
|
||||
started_at: UtcDateTime
|
||||
paused_at: Optional[UtcDateTime]
|
||||
closed_at: Optional[UtcDateTime]
|
||||
closed_by: Optional[int]
|
||||
statistics_path: Optional[str] = None
|
||||
|
||||
@@ -49,12 +49,14 @@ class ProductionRunResponse(BaseModel):
|
||||
# overdue is a fact the operator must see, not deduce.
|
||||
seconds_to_next_measurement: Optional[int] = None
|
||||
overdue: bool = False
|
||||
server_time: Optional[datetime] = None
|
||||
server_time: Optional[UtcDateTime] = None
|
||||
|
||||
# The measurement tasks of this run's version, in execution order. The client
|
||||
# needs them to bring the operator back to the measurement when the interval
|
||||
# expires, whichever screen they are on, and to know which task closes a cycle.
|
||||
measurement_task_ids: list[int] = Field(default_factory=list)
|
||||
# #53: task 'note' con at_close, da compilare prima della modale del capoturno.
|
||||
close_task_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||
@@ -64,8 +66,21 @@ class ProductionRunWithEventsResponse(ProductionRunResponse):
|
||||
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)
|
||||
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||
supervisor_username: Optional[str] = Field(None, min_length=1)
|
||||
supervisor_password: Optional[str] = Field(None, min_length=1)
|
||||
supervisor_user_id: Optional[int] = Field(None, gt=0)
|
||||
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_credential_pair(self):
|
||||
by_password = self.supervisor_username and self.supervisor_password
|
||||
by_pin = self.supervisor_user_id and self.supervisor_pin
|
||||
if not (by_password or by_pin):
|
||||
raise ValueError(
|
||||
"supervisor credentials required: username+password or user_id+pin"
|
||||
)
|
||||
return self
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -26,6 +26,12 @@ class RecipeCreate(BaseModel):
|
||||
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)
|
||||
@@ -51,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"] = []
|
||||
|
||||
@@ -72,7 +78,7 @@ class RecipeResponse(BaseModel):
|
||||
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):
|
||||
|
||||
@@ -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,14 +1,110 @@
|
||||
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
|
||||
import re
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from src.backend.models.orm.task import TASK_TYPES
|
||||
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
|
||||
|
||||
# Kept in step with the ORM enum so the two cannot drift apart.
|
||||
TaskType = Literal[TASK_TYPES]
|
||||
|
||||
|
||||
def validate_tolerance_order(
|
||||
ltl: Optional[float],
|
||||
lwl: Optional[float],
|
||||
nominal: Optional[float],
|
||||
uwl: Optional[float],
|
||||
utl: Optional[float],
|
||||
) -> None:
|
||||
"""ltl <= lwl <= nominal <= uwl <= utl, checked only across the values set.
|
||||
|
||||
A one-sided tolerance - only utl, say - is not an error: what is wrong is
|
||||
limits that invert the sequence they do share, like utl landing below ltl.
|
||||
Shared by SubtaskCreate's own validator and by the router's partial-update
|
||||
check (SubtaskUpdate has no fixed set of fields to compare).
|
||||
"""
|
||||
ordered = [
|
||||
("ltl", ltl), ("lwl", lwl), ("nominal", nominal), ("uwl", uwl), ("utl", utl),
|
||||
]
|
||||
present = [(name, value) for name, value in ordered if value is not None]
|
||||
for (prev_name, prev), (name, value) in zip(present, present[1:]):
|
||||
if prev > value:
|
||||
raise ValueError(
|
||||
f"Tolerance limits out of order: {prev_name}={prev} > {name}={value}"
|
||||
)
|
||||
|
||||
|
||||
# #55: campi extra di un task 'note'
|
||||
MAX_NOTE_FIELDS = 20
|
||||
_FIELD_KEY_RE = re.compile(r"^[a-z0-9_]{1,40}$")
|
||||
NOTE_FIELD_TYPES = ("text", "number")
|
||||
|
||||
|
||||
def normalize_note_fields(fields: Optional[list]) -> Optional[list[dict]]:
|
||||
"""Valida e normalizza `fields` di un task 'note' (#55); lista vuota -> None.
|
||||
|
||||
Solleva ValueError con un messaggio leggibile (il router lo trasforma in 422).
|
||||
"""
|
||||
if not fields:
|
||||
return None
|
||||
if len(fields) > MAX_NOTE_FIELDS:
|
||||
raise ValueError(f"Al massimo {MAX_NOTE_FIELDS} campi per task")
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for i, raw in enumerate(fields, start=1):
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Campo {i}: formato non valido")
|
||||
key = raw.get("key")
|
||||
if not isinstance(key, str) or not _FIELD_KEY_RE.match(key):
|
||||
raise ValueError(
|
||||
f"Campo {i}: chiave non valida (solo a-z, 0-9, _, da 1 a 40 caratteri)"
|
||||
)
|
||||
if key in seen:
|
||||
raise ValueError(f"Campo {i}: chiave '{key}' duplicata")
|
||||
seen.add(key)
|
||||
label = raw.get("label")
|
||||
label = label.strip() if isinstance(label, str) else ""
|
||||
if not 1 <= len(label) <= 100:
|
||||
raise ValueError(f"Campo '{key}': etichetta da 1 a 100 caratteri")
|
||||
ftype = raw.get("type")
|
||||
if ftype not in NOTE_FIELD_TYPES:
|
||||
raise ValueError(f"Campo '{key}': tipo deve essere text o number")
|
||||
default = raw.get("default")
|
||||
if default is not None:
|
||||
if not isinstance(default, str):
|
||||
raise ValueError(f"Campo '{key}': il valore predefinito deve essere testo")
|
||||
if default == "":
|
||||
default = None
|
||||
if default is not None and ftype == "number":
|
||||
try:
|
||||
float(default.replace(",", "."))
|
||||
except ValueError:
|
||||
raise ValueError(f"Campo '{key}': il valore predefinito non e' un numero")
|
||||
attachment = raw.get("attachment", False)
|
||||
if not isinstance(attachment, bool):
|
||||
raise ValueError(f"Campo '{key}': attachment deve essere true o false")
|
||||
out.append({
|
||||
"key": key, "label": label, "type": ftype,
|
||||
"default": default, "attachment": attachment,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def check_task_loop_options(task_type: str, at_close: bool, fields: Optional[list]) -> None:
|
||||
"""at_close e fields sono ammessi solo per i task 'note' (#53 #55)."""
|
||||
if task_type != "note":
|
||||
if at_close:
|
||||
raise ValueError("'Alla chiusura' e' ammesso solo per i task di tipo note")
|
||||
if fields:
|
||||
raise ValueError("I campi extra sono ammessi solo per i task di tipo note")
|
||||
|
||||
|
||||
def default_in_loop(task_type: str) -> bool:
|
||||
"""in_loop quando non dichiarato: misure e check si', gli altri no (#53)."""
|
||||
return task_type in DEFAULT_LOOP_TASK_TYPES
|
||||
|
||||
|
||||
class SubtaskCreate(BaseModel):
|
||||
"""Schema for creating a subtask."""
|
||||
marker_number: int = Field(..., ge=1)
|
||||
@@ -23,6 +119,11 @@ class SubtaskCreate(BaseModel):
|
||||
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):
|
||||
"""Schema for updating a subtask."""
|
||||
@@ -68,6 +169,10 @@ class TaskCreate(BaseModel):
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
# #53: assente = default dal tipo (misure e check nel ciclo)
|
||||
in_loop: Optional[bool] = None
|
||||
at_close: bool = False
|
||||
fields: Optional[list[dict[str, Any]]] = None
|
||||
subtasks: list[SubtaskCreate] = []
|
||||
|
||||
|
||||
@@ -81,6 +186,9 @@ class TaskUpdate(BaseModel):
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
in_loop: Optional[bool] = None
|
||||
at_close: Optional[bool] = None
|
||||
fields: Optional[list[dict[str, Any]]] = None
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
@@ -99,6 +207,9 @@ class TaskResponse(BaseModel):
|
||||
file_type: Optional[str] = None
|
||||
annotations_json: Optional[dict[str, Any]] = None
|
||||
vision_json: Optional[dict] = None
|
||||
in_loop: bool = False
|
||||
at_close: bool = False
|
||||
fields: Optional[list[dict[str, Any]]] = None
|
||||
subtasks: list[SubtaskResponse] = []
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Pydantic schemas for task results (#54 check OK/NOK, #55 note con campi)."""
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
|
||||
|
||||
class TaskResultAttachment(BaseModel):
|
||||
"""Un allegato: `path` e' il percorso restituito da /api/files/upload."""
|
||||
key: Optional[str] = Field(None, max_length=40)
|
||||
path: str = Field(..., min_length=1, max_length=500)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class TaskResultCreate(BaseModel):
|
||||
task_id: int = Field(..., gt=0)
|
||||
production_run_id: Optional[int] = Field(None, gt=0)
|
||||
outcome: Literal["ok", "nok", "done"]
|
||||
note: Optional[str] = None
|
||||
values: Optional[dict[str, str]] = None
|
||||
attachments: Optional[list[TaskResultAttachment]] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class TaskResultResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
task_id: int
|
||||
version_id: int
|
||||
production_run_id: Optional[int] = None
|
||||
user_id: int
|
||||
outcome: str
|
||||
note: Optional[str] = None
|
||||
values: Optional[dict[str, str]] = None
|
||||
attachments: Optional[list[TaskResultAttachment]] = None
|
||||
created_at: UtcDateTime
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Shared Pydantic types for datetimes (#42): UTC in the DB, explicit +00:00 on the wire."""
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from pydantic import AfterValidator, PlainSerializer
|
||||
|
||||
from src.backend.utils.tempo import as_utc_naive, to_utc_aware
|
||||
|
||||
UtcDateTime = Annotated[
|
||||
datetime, PlainSerializer(lambda v: to_utc_aware(v).isoformat(), return_type=str)
|
||||
]
|
||||
|
||||
# Input: aware values are converted to UTC naive, naive is taken as UTC.
|
||||
UtcNaiveInput = Annotated[Optional[datetime], AfterValidator(as_utc_naive)]
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for User operations."""
|
||||
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,10 @@ 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
|
||||
# #59: solo il fatto che il PIN esista, mai l'hash
|
||||
has_pin: bool = False
|
||||
|
||||
|
||||
class UserPasswordChange(BaseModel):
|
||||
@@ -68,3 +70,21 @@ class LoginResponse(BaseModel):
|
||||
user: UserResponse
|
||||
api_key: str
|
||||
message: str = "Login successful"
|
||||
|
||||
|
||||
class PinUserItem(BaseModel):
|
||||
"""Voce dell'elenco pubblico per il login con PIN (#59): solo id e nome."""
|
||||
id: int
|
||||
display_name: str
|
||||
|
||||
|
||||
class PinLoginRequest(BaseModel):
|
||||
"""Login con PIN (#59)."""
|
||||
user_id: int
|
||||
pin: str = Field(..., pattern=r"^[0-9]{4}$")
|
||||
|
||||
|
||||
class PinSet(BaseModel):
|
||||
"""Imposta (4 cifre) o toglie (null) il PIN di un utente (#59)."""
|
||||
pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Pydantic schemas for the vision execution response."""
|
||||
from pydantic import BaseModel
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class VisionMeasurementResult(BaseModel):
|
||||
@@ -14,3 +16,44 @@ class VisionExecuteResponse(BaseModel):
|
||||
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,7 +1,9 @@
|
||||
"""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.task_result import TaskResult
|
||||
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
|
||||
@@ -11,10 +13,12 @@ from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"ApiKey",
|
||||
"Recipe",
|
||||
"RecipeVersion",
|
||||
"RecipeTask",
|
||||
"RecipeSubtask",
|
||||
"TaskResult",
|
||||
"Measurement",
|
||||
"AccessLog",
|
||||
"SystemSetting",
|
||||
|
||||
@@ -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)
|
||||
@@ -2,7 +2,7 @@
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||
Boolean, DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -15,10 +15,21 @@ if TYPE_CHECKING:
|
||||
# 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")
|
||||
# 'check' = Controllo OK/NOK, senza quote (#54).
|
||||
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure", "check")
|
||||
|
||||
# Types the operator screen treats as "there are quotes to take here".
|
||||
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
||||
# dxf_compare produce quote come camera_measure (#46)
|
||||
MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare")
|
||||
|
||||
# Tipi che entrano nel ciclo di produzione se in_loop non e' dichiarato (#53):
|
||||
# le misure di sempre piu' il controllo OK/NOK.
|
||||
DEFAULT_LOOP_TASK_TYPES = MEASURING_TASK_TYPES + ("check",)
|
||||
|
||||
|
||||
def _default_in_loop(context) -> bool:
|
||||
"""Default alla creazione di in_loop: dipende dal tipo del task (#53)."""
|
||||
return context.get_current_parameters().get("task_type", "note") in DEFAULT_LOOP_TASK_TYPES
|
||||
|
||||
|
||||
class RecipeTask(Base):
|
||||
@@ -51,6 +62,20 @@ class RecipeTask(Base):
|
||||
# server routes it and never interprets it, which is what keeps VisionSuite
|
||||
# out of this container.
|
||||
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||
# meta della ricetta VisionSuite v2 (#46).
|
||||
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
|
||||
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
||||
in_loop: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=_default_in_loop, server_default="0"
|
||||
)
|
||||
# #53: task 'note' compilato alla fine produzione, prima della modale del capoturno.
|
||||
at_close: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
# #55: campi extra del task 'note' ([{key,label,type,default,attachment}]).
|
||||
fields: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""TaskResult: l'esito registrato da un task 'check' (OK/NOK) o 'note' (#54 #55)."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
TASK_OUTCOMES = ("ok", "nok", "done")
|
||||
|
||||
|
||||
class TaskResult(Base):
|
||||
__tablename__ = "task_results"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
# Copiata dal task: la versione in cui l'esito e' stato dato non cambia piu'.
|
||||
version_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
|
||||
)
|
||||
production_run_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("production_runs.id", ondelete="SET NULL"),
|
||||
nullable=True, index=True,
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
outcome: Mapped[str] = mapped_column(
|
||||
Enum(*TASK_OUTCOMES, name="task_outcome_enum"), nullable=False
|
||||
)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
# {chiave del campo: valore stringa}; allegati: [{key, path, name}]
|
||||
values: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
attachments: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TaskResult task={self.task_id} outcome={self.outcome}>"
|
||||
@@ -22,6 +22,13 @@ class User(Base):
|
||||
# Admin flag separate from roles
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# PIN a 4 cifre (#59): solo l'hash; contatore errori e fine blocco per il lockout
|
||||
pin_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
pin_failed_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
pin_locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# User preferences
|
||||
language_pref: Mapped[str] = mapped_column(
|
||||
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
||||
@@ -32,7 +39,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(
|
||||
@@ -40,6 +46,11 @@ class User(Base):
|
||||
)
|
||||
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
@property
|
||||
def has_pin(self) -> bool:
|
||||
"""True se l'utente ha un PIN impostato (#59); serve a UserResponse.has_pin."""
|
||||
return self.pin_hash is not None
|
||||
|
||||
def has_role(self, role: str) -> bool:
|
||||
"""Check if user has a specific role."""
|
||||
return role in (self.roles or [])
|
||||
|
||||
@@ -13,7 +13,8 @@ class VisionResult(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
||||
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)
|
||||
@@ -48,7 +49,8 @@ class VisionReferenceImage(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True
|
||||
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(
|
||||
|
||||
@@ -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,12 +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 fastapi import HTTPException, status
|
||||
from sqlalchemy import select, update
|
||||
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
|
||||
|
||||
|
||||
@@ -27,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:
|
||||
@@ -35,12 +46,87 @@ 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 authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
|
||||
# #59: PIN a 4 cifre. Hash identico alle password (bcrypt); 5 errori -> blocco 15 minuti.
|
||||
PIN_MAX_FAILURES = 5
|
||||
PIN_LOCK_MINUTES = 15
|
||||
|
||||
|
||||
async def set_user_pin(db: AsyncSession, user: User, pin: str | None) -> None:
|
||||
"""Imposta o toglie (None) il PIN; azzera contatore e blocco. Admin esclusi (#59)."""
|
||||
if pin is not None and user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Admin users cannot have a PIN",
|
||||
)
|
||||
user.pin_hash = hash_password(pin) if pin is not None else None
|
||||
user.pin_failed_count = 0
|
||||
user.pin_locked_until = None
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def authenticate_pin(db: AsyncSession, user_id: int, pin: str) -> User:
|
||||
"""Verifica user_id+PIN con blocco dopo 5 errori consecutivi (#59).
|
||||
|
||||
401 PIN errato (o utente non idoneo: stessa risposta, niente enumerazione),
|
||||
423 utente bloccato. Il contatore va committato prima di sollevare: get_db fa
|
||||
rollback su eccezione e l'errore andrebbe perso.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.active == True) # noqa: E712
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None or user.is_admin or user.pin_hash is None:
|
||||
verify_password(pin, _DUMMY_PASSWORD_HASH) # stesso costo di un PIN errato
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN"
|
||||
)
|
||||
now = utcnow()
|
||||
if user.pin_locked_until is not None:
|
||||
if user.pin_locked_until > now:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_423_LOCKED,
|
||||
detail="PIN locked until "
|
||||
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
|
||||
+ " UTC",
|
||||
)
|
||||
# blocco scaduto: si riparte da zero
|
||||
user.pin_locked_until = None
|
||||
user.pin_failed_count = 0
|
||||
if verify_password(pin, user.pin_hash):
|
||||
if user.pin_failed_count:
|
||||
user.pin_failed_count = 0
|
||||
await db.flush()
|
||||
return user
|
||||
user.pin_failed_count += 1
|
||||
locked = user.pin_failed_count >= PIN_MAX_FAILURES
|
||||
if locked:
|
||||
user.pin_locked_until = now + timedelta(minutes=PIN_LOCK_MINUTES)
|
||||
await db.commit()
|
||||
if locked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_423_LOCKED,
|
||||
detail="PIN locked until "
|
||||
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
|
||||
+ " UTC",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN")
|
||||
|
||||
|
||||
async def authorise_supervisor(
|
||||
db: AsyncSession,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
user_id: int | None = None,
|
||||
pin: str | None = None,
|
||||
) -> User:
|
||||
"""Check the credentials of the supervisor authorising something.
|
||||
|
||||
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
||||
@@ -48,10 +134,19 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
||||
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 rotate the
|
||||
supervisor's API key and knock out whatever session they have open elsewhere.
|
||||
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 user_id is not None and pin is not None:
|
||||
# #59: via PIN, stesso blocco del login con PIN (401/423 sollevati da li')
|
||||
supervisor = await authenticate_pin(db, user_id, pin)
|
||||
elif username is not None and password is not None:
|
||||
supervisor = await authenticate_user(db, username, password)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Supervisor credentials required",
|
||||
)
|
||||
if supervisor is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -65,24 +160,67 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
||||
return supervisor
|
||||
|
||||
|
||||
async def login_user(db: AsyncSession, user: User) -> str:
|
||||
"""Generate API key and update last_login for user."""
|
||||
api_key = generate_api_key()
|
||||
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(
|
||||
@@ -113,11 +251,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,18 +1,54 @@
|
||||
"""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 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.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(
|
||||
value: float, subtask: RecipeSubtask
|
||||
@@ -25,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)))
|
||||
@@ -202,12 +243,108 @@ async def task_progress(
|
||||
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 = datetime.now()
|
||||
measurement.authorised_at = utcnow()
|
||||
await db.flush()
|
||||
await db.refresh(measurement)
|
||||
return measurement
|
||||
@@ -236,6 +373,8 @@ async def save_measurement(
|
||||
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)
|
||||
@@ -244,6 +383,22 @@ 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)
|
||||
|
||||
@@ -263,6 +418,10 @@ async def save_measurement(
|
||||
),
|
||||
)
|
||||
|
||||
# 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(
|
||||
|
||||
@@ -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
|
||||
@@ -9,9 +9,12 @@ The CSV uses the delimiters configured in system_settings, the same ones the man
|
||||
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
|
||||
@@ -25,10 +28,38 @@ 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",
|
||||
@@ -75,8 +106,17 @@ async def _csv_format(db: AsyncSession) -> tuple[str, str]:
|
||||
)
|
||||
)
|
||||
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.setting_value if delimiter else ",",
|
||||
delimiter_value,
|
||||
decimal.setting_value if decimal else ".",
|
||||
)
|
||||
|
||||
@@ -109,15 +149,15 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
||||
for measurement, subtask in rows:
|
||||
writer.writerow([
|
||||
run.id,
|
||||
station.code if station else "",
|
||||
recipe.code if recipe else "",
|
||||
neutralise_csv_cell(station.code) if station else "",
|
||||
neutralise_csv_cell(recipe.code) if recipe else "",
|
||||
measurement.version_id,
|
||||
measurement.lot_number or "",
|
||||
measurement.serial_number or "",
|
||||
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 "",
|
||||
subtask.description if subtask else "",
|
||||
neutralise_csv_cell(subtask.description) if subtask else "",
|
||||
number(subtask.nominal) if subtask else "",
|
||||
number(measurement.value),
|
||||
number(measurement.deviation),
|
||||
@@ -130,9 +170,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
||||
measurement.input_method,
|
||||
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
||||
measurement.measured_by,
|
||||
measurement.measured_at.isoformat() if measurement.measured_at else "",
|
||||
iso_utc(measurement.measured_at),
|
||||
measurement.supervisor_id or "",
|
||||
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
|
||||
iso_utc(measurement.authorised_at),
|
||||
])
|
||||
|
||||
return output.getvalue()
|
||||
@@ -152,9 +192,13 @@ async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | N
|
||||
directory = Path(settings.upload_path) / EXPORT_SUBDIR
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
|
||||
stamp = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"production_{run.id}_{stamp}.csv"
|
||||
(directory / filename).write_text(content, encoding="utf-8-sig")
|
||||
# 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.
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -23,7 +24,7 @@ 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
|
||||
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 (
|
||||
@@ -34,10 +35,10 @@ from src.backend.services import (
|
||||
def _now() -> datetime:
|
||||
"""Single source of time for runs.
|
||||
|
||||
Naive local time, matching what the database writes for ``func.now()`` defaults,
|
||||
Naive UTC, matching what the database writes for ``func.now()`` defaults,
|
||||
so a run's started_at and its deadline are comparable.
|
||||
"""
|
||||
return datetime.now()
|
||||
return utcnow()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -106,6 +107,29 @@ async def get_station_by_code(db: AsyncSession, code: str) -> Station:
|
||||
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.
|
||||
|
||||
@@ -121,7 +145,21 @@ async def measurement_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||
select(RecipeTask.id)
|
||||
.where(
|
||||
RecipeTask.version_id == version_id,
|
||||
RecipeTask.task_type.in_(MEASURING_TASK_TYPES),
|
||||
# #53: il ciclo lo dichiara il task (in_loop), non piu' il solo tipo.
|
||||
RecipeTask.in_loop.is_(True),
|
||||
)
|
||||
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def close_task_ids(db: AsyncSession, version_id: int) -> list[int]:
|
||||
"""Ids dei task da compilare alla fine produzione (at_close), in ordine (#53)."""
|
||||
result = await db.execute(
|
||||
select(RecipeTask.id)
|
||||
.where(
|
||||
RecipeTask.version_id == version_id,
|
||||
RecipeTask.at_close.is_(True),
|
||||
)
|
||||
.order_by(RecipeTask.order_index, RecipeTask.id)
|
||||
)
|
||||
@@ -199,6 +237,12 @@ async def open_run(
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -34,13 +35,34 @@ HEADER_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
# 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."""
|
||||
return {
|
||||
field: getattr(data, field)
|
||||
for field in HEADER_FIELDS
|
||||
if getattr(data, field) is not None
|
||||
}
|
||||
"""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:
|
||||
@@ -93,10 +115,38 @@ async def _copy_tasks_to_version(
|
||||
# a camera_measure task unable to execute from the moment a
|
||||
# recipe with measurements is next edited.
|
||||
vision_json=task.vision_json,
|
||||
# Cartella immutabile: basta il riferimento (#46).
|
||||
vision_recipe_dir=task.vision_recipe_dir,
|
||||
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
||||
in_loop=task.in_loop,
|
||||
at_close=task.at_close,
|
||||
fields=task.fields,
|
||||
)
|
||||
db.add(new_task)
|
||||
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,
|
||||
@@ -209,6 +259,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,
|
||||
@@ -425,6 +547,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
|
||||
|
||||
@@ -5,7 +5,9 @@ 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
|
||||
|
||||
@@ -16,40 +18,92 @@ 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,
|
||||
})
|
||||
|
||||
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||
|
||||
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"). A stopped worker raises `httpx.ConnectError`; a job
|
||||
past the timeout raises `httpx.ReadTimeout` - 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.
|
||||
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={"graph": json.dumps(graph)},
|
||||
data=data,
|
||||
)
|
||||
except httpx.ConnectError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker is unreachable: {exc}",
|
||||
) from exc
|
||||
except httpx.ReadTimeout as exc:
|
||||
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(
|
||||
@@ -82,6 +136,188 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||
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,
|
||||
@@ -92,7 +328,37 @@ async def execute_task(
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None,
|
||||
) -> tuple[VisionResult, list[Measurement]]:
|
||||
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,
|
||||
@@ -141,12 +407,21 @@ async def execute_task(
|
||||
),
|
||||
)
|
||||
|
||||
report = await _call_worker(image_bytes, task.vision_json)
|
||||
# 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.
|
||||
missing = [q.vision_output for q in quotes if q.vision_output not in outputs]
|
||||
# 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(
|
||||
@@ -157,8 +432,12 @@ async def execute_task(
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -183,7 +462,38 @@ async def execute_task(
|
||||
enforce_tolerance_gate=False,
|
||||
))
|
||||
|
||||
return result, saved
|
||||
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(
|
||||
@@ -202,17 +512,22 @@ async def save_reference_image(
|
||||
`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).
|
||||
folder = settings.upload_path / "vision" / "reference" / str(task_id)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid4().hex}.{extension}"
|
||||
(folder / name).write_bytes(image_bytes)
|
||||
relative_path = str(Path("vision") / "reference" / str(task_id) / name)
|
||||
|
||||
reference = VisionReferenceImage(
|
||||
task_id=task_id,
|
||||
path=str(Path("vision") / "reference" / str(task_id) / name),
|
||||
path=relative_path,
|
||||
note=note,
|
||||
station_id=station_id,
|
||||
device_code=device_code,
|
||||
@@ -220,10 +535,46 @@ async def save_reference_image(
|
||||
)
|
||||
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:
|
||||
@@ -231,4 +582,6 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="this task carries no vision graph",
|
||||
)
|
||||
return await _call_worker(image_bytes, task.vision_json)
|
||||
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
|
||||
|
||||
|
||||
@@ -278,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,235 @@
|
||||
"""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()
|
||||
|
||||
|
||||
class TestUserPinMigration:
|
||||
"""017_user_pin: users.pin_hash / pin_failed_count / pin_locked_until, su e giu (#59)."""
|
||||
|
||||
def test_017_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
new = {"pin_hash", "pin_failed_count", "pin_locked_until"}
|
||||
|
||||
def cols():
|
||||
return {c["name"] for c in _inspect(db_path).get_columns("users")}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert new <= cols()
|
||||
command.downgrade(cfg, "016_vision_recipe_dir")
|
||||
assert not (new & cols())
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert new <= cols()
|
||||
|
||||
|
||||
_SEED_USER_RECIPE_VERSION = [
|
||||
"INSERT INTO users (username, password_hash, display_name, roles, is_admin, active,"
|
||||
" language_pref, theme_pref) VALUES ('u', 'x', 'U', '[]', 1, 1, 'en', 'light')",
|
||||
"INSERT INTO recipes (code, name, active, created_by) VALUES ('R', 'R', 1, 1)",
|
||||
"INSERT INTO recipe_versions (recipe_id, version_number, is_current, created_by)"
|
||||
" VALUES (1, 1, 1, 1)",
|
||||
]
|
||||
|
||||
|
||||
class TestTaskLoopResultsMigration:
|
||||
"""018_task_loop_results: in_loop/at_close/fields, tipo check, task_results (#53 #54 #55)."""
|
||||
|
||||
def test_018_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 {"in_loop", "at_close", "fields"} <= cols()
|
||||
assert "task_results" in _inspect(db_path).get_table_names()
|
||||
command.downgrade(cfg, "017_user_pin")
|
||||
assert not ({"in_loop", "at_close", "fields"} & cols())
|
||||
assert "task_results" not in _inspect(db_path).get_table_names()
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
|
||||
def test_018_marks_existing_measure_tasks_in_loop(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "017_user_pin")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
for stmt in _SEED_USER_RECIPE_VERSION:
|
||||
conn.execute(text(stmt))
|
||||
for i, t in enumerate(("measure", "camera_measure", "dxf_compare", "note", "drawing")):
|
||||
conn.execute(text(
|
||||
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
|
||||
f" VALUES (1, {i}, '{t}', '{t}')"))
|
||||
engine.dispose()
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.connect() as conn:
|
||||
rows = dict(conn.execute(text("SELECT task_type, in_loop FROM recipe_tasks")).all())
|
||||
engine.dispose()
|
||||
assert rows == {"measure": 1, "camera_measure": 1, "dxf_compare": 1, "note": 0, "drawing": 0}
|
||||
|
||||
def test_018_downgrade_refused_when_a_check_task_exists(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "head")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
for stmt in _SEED_USER_RECIPE_VERSION:
|
||||
conn.execute(text(stmt))
|
||||
conn.execute(text(
|
||||
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
|
||||
" VALUES (1, 0, 'check', 'c')"))
|
||||
engine.dispose()
|
||||
|
||||
with pytest.raises(RuntimeError, match="check"):
|
||||
command.downgrade(cfg, "017_user_pin")
|
||||
@@ -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,175 @@
|
||||
"""PIN a 4 cifre: login rapido, blocco, capoturno via PIN (#59)."""
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import auth_service
|
||||
from src.backend.tests.conftest import _create_user, auth_headers
|
||||
from src.backend.tests.test_production_runs import (
|
||||
_open, _recipe_with_interval, _station,
|
||||
)
|
||||
from src.backend.utils.tempo import utcnow
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _alza_limite_login(monkeypatch):
|
||||
# #65: login-pin conta nel bucket del login (limite al minuto); i test del blocco PIN
|
||||
# fanno piu tentativi di fila, quindi qui il limite si alza. Il middleware resta com'e.
|
||||
monkeypatch.setattr(settings, "rate_limit_login", 1000)
|
||||
|
||||
|
||||
async def _pin_user(db_session, username="operaio", pin="1234", **kw) -> User:
|
||||
user = await _create_user(db_session, username=username, **kw)
|
||||
await auth_service.set_user_pin(db_session, user, pin)
|
||||
await db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
async def _login_pin(client, user_id, pin):
|
||||
return await client.post("/api/auth/login-pin", json={"user_id": user_id, "pin": pin})
|
||||
|
||||
|
||||
async def test_login_pin_ok_same_shape_as_login(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
resp = await _login_pin(client, user.id, "1234")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["user"]["username"] == "operaio"
|
||||
assert data["user"]["has_pin"] is True
|
||||
assert data["api_key"]
|
||||
assert "pin_hash" not in data["user"]
|
||||
# la api_key vale davvero
|
||||
me = await client.get("/api/auth/me", headers={"X-API-Key": data["api_key"]})
|
||||
assert me.status_code == 200
|
||||
|
||||
|
||||
async def test_login_pin_wrong_is_401(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
assert (await _login_pin(client, user.id, "9999")).status_code == 401
|
||||
|
||||
|
||||
async def test_login_pin_format_422(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for bad in ("123", "12345", "12a4", ""):
|
||||
assert (await _login_pin(client, user.id, bad)).status_code == 422
|
||||
|
||||
|
||||
async def test_lock_after_five_errors_even_with_right_pin(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for _ in range(4):
|
||||
assert (await _login_pin(client, user.id, "0000")).status_code == 401
|
||||
resp = await _login_pin(client, user.id, "0000") # 5 errore: scatta il blocco
|
||||
assert resp.status_code == 423
|
||||
assert "locked until" in resp.json()["detail"]
|
||||
assert (await _login_pin(client, user.id, "1234")).status_code == 423
|
||||
|
||||
|
||||
async def test_lock_expires_and_counter_resets(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for _ in range(5):
|
||||
await _login_pin(client, user.id, "0000")
|
||||
row = (await db_session.execute(select(User).where(User.id == user.id))).scalar_one()
|
||||
await db_session.refresh(row)
|
||||
assert row.pin_locked_until > utcnow() + timedelta(minutes=14)
|
||||
# tempo simulato: il blocco e scaduto un secondo fa
|
||||
row.pin_locked_until = utcnow() - timedelta(seconds=1)
|
||||
await db_session.commit()
|
||||
assert (await _login_pin(client, user.id, "1234")).status_code == 200
|
||||
await db_session.refresh(row)
|
||||
assert row.pin_failed_count == 0 and row.pin_locked_until is None
|
||||
|
||||
|
||||
async def test_success_resets_failed_counter(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for _ in range(3):
|
||||
await _login_pin(client, user.id, "0000")
|
||||
assert (await _login_pin(client, user.id, "1234")).status_code == 200
|
||||
for _ in range(4): # il contatore e ripartito da zero: 4 errori non bloccano
|
||||
assert (await _login_pin(client, user.id, "0000")).status_code == 401
|
||||
|
||||
|
||||
async def test_admin_cannot_have_pin_or_login_with_it(client: AsyncClient, admin_user, db_session):
|
||||
resp = await client.put(
|
||||
f"/api/users/{admin_user.id}/pin", headers=auth_headers(admin_user), json={"pin": "1234"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
# anche un admin con pin_hash scritto a mano non entra da login-pin
|
||||
admin_user.pin_hash = auth_service.hash_password("1234")
|
||||
await db_session.commit()
|
||||
assert (await _login_pin(client, admin_user.id, "1234")).status_code == 401
|
||||
listed = (await client.get("/api/auth/pin-users")).json()
|
||||
assert all(u["id"] != admin_user.id for u in listed)
|
||||
|
||||
|
||||
async def test_put_pin_set_and_remove(client: AsyncClient, admin_user, db_session):
|
||||
user = await _create_user(db_session, username="senzapin")
|
||||
await db_session.commit()
|
||||
h = auth_headers(admin_user)
|
||||
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "4321"})).status_code == 200
|
||||
assert (await _login_pin(client, user.id, "4321")).status_code == 200
|
||||
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "12"})).status_code == 422
|
||||
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": None})).status_code == 200
|
||||
assert (await _login_pin(client, user.id, "4321")).status_code == 401
|
||||
assert (await client.put("/api/users/99999/pin", headers=h, json={"pin": "1234"})).status_code == 404
|
||||
# non admin: 403
|
||||
assert (await client.put(
|
||||
f"/api/users/{user.id}/pin", headers=auth_headers(user), json={"pin": "1234"},
|
||||
)).status_code == 403
|
||||
|
||||
|
||||
async def test_pin_users_list(client: AsyncClient, db_session):
|
||||
a = await _pin_user(db_session, username="zeta", display_name="Zeta Rossi", pin="1111")
|
||||
b = await _pin_user(db_session, username="alfa", display_name="Alfa Bianchi", pin="2222")
|
||||
await _create_user(db_session, username="nopin")
|
||||
await _pin_user(db_session, username="inattivo", pin="3333", active=False)
|
||||
await db_session.commit()
|
||||
resp = await client.get("/api/auth/pin-users") # senza autenticazione
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert [u["display_name"] for u in data] == ["Alfa Bianchi", "Zeta Rossi"]
|
||||
assert all(set(u) == {"id", "display_name"} for u in data)
|
||||
assert {u["id"] for u in data} == {a.id, b.id}
|
||||
|
||||
|
||||
async def test_supervisor_via_pin(client: AsyncClient, measurement_tec_user, admin_user, db_session):
|
||||
station = await _station(db_session, admin_user.id, code="ST-PIN")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PIN")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
capo = await _pin_user(db_session, username="capo-pin", pin="5555", roles=["Supervisor"])
|
||||
h = auth_headers(measurement_tec_user)
|
||||
|
||||
bad = await client.post(
|
||||
f"/api/production-runs/{run_id}/pause", headers=h,
|
||||
json={"supervisor_user_id": capo.id, "supervisor_pin": "0000"},
|
||||
)
|
||||
assert bad.status_code == 401
|
||||
ok = await client.post(
|
||||
f"/api/production-runs/{run_id}/pause", headers=h,
|
||||
json={"supervisor_user_id": capo.id, "supervisor_pin": "5555"},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
# nessuna coppia di credenziali: 422
|
||||
none = await client.post(f"/api/production-runs/{run_id}/resume", headers=h, json={})
|
||||
assert none.status_code == 422
|
||||
|
||||
|
||||
async def test_supervisor_pin_role_still_checked(db_session):
|
||||
plain = await _pin_user(db_session, username="non-capo", pin="6666", roles=["Maker"])
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await auth_service.authorise_supervisor(db_session, user_id=plain.id, pin="6666")
|
||||
assert e.value.status_code == 403
|
||||
|
||||
|
||||
async def test_supervisor_pin_lockout(db_session):
|
||||
capo = await _pin_user(db_session, username="capo-lock", pin="7777", roles=["Supervisor"])
|
||||
codes = []
|
||||
for pin in ["0000"] * 5 + ["7777"]:
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await auth_service.authorise_supervisor(db_session, user_id=capo.id, pin=pin)
|
||||
codes.append(e.value.status_code)
|
||||
assert codes == [401, 401, 401, 401, 423, 423]
|
||||
@@ -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 == "."
|
||||
@@ -175,6 +175,97 @@ async def test_open_run_rejects_unknown_station(
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role and station-assignment gate (gitea#24)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_open_run_requires_measurement_tec_or_supervisor(
|
||||
client: AsyncClient, maker_user, admin_user, db_session,
|
||||
):
|
||||
"""A Maker with no floor role must not be able to start a production."""
|
||||
station = await _station(db_session, admin_user.id, code="ST-ROLE")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ROLE")
|
||||
|
||||
resp = await _open(client, maker_user, station, recipe)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_open_run_rejects_recipe_not_assigned_to_station(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
from src.backend.services import station_service
|
||||
|
||||
station = await _station(db_session, admin_user.id, code="ST-ASSIGN")
|
||||
assigned_recipe = await _recipe_with_interval(
|
||||
db_session, admin_user.id, 15, code="REC-ASSIGNED"
|
||||
)
|
||||
other_recipe = await _recipe_with_interval(
|
||||
db_session, admin_user.id, 15, code="REC-OTHER"
|
||||
)
|
||||
await station_service.assign_recipe(db_session, station.id, assigned_recipe.id, admin_user)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await _open(client, measurement_tec_user, station, other_recipe)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_open_run_allows_recipe_assigned_to_station(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
from src.backend.services import station_service
|
||||
|
||||
station = await _station(db_session, admin_user.id, code="ST-ASSIGNOK")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-ASSIGNOK")
|
||||
await station_service.assign_recipe(db_session, station.id, recipe.id, admin_user)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await _open(client, measurement_tec_user, station, recipe)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
async def test_open_run_allows_any_recipe_when_station_has_no_assignment(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""An unconfigured station is not restricted - matches the read-side filter
|
||||
in station_service.list_station_recipes (empty assignments = not filtering)."""
|
||||
station = await _station(db_session, admin_user.id, code="ST-FREE")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-FREE")
|
||||
|
||||
resp = await _open(client, measurement_tec_user, station, recipe)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
async def test_open_run_rejects_inactive_recipe(
|
||||
client: AsyncClient, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
station = await _station(db_session, admin_user.id, code="ST-INACTIVE")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 15, code="REC-INACTIVE")
|
||||
recipe.active = False
|
||||
await db_session.commit()
|
||||
|
||||
resp = await _open(client, measurement_tec_user, station, recipe)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
async def test_pause_requires_measurement_tec_or_supervisor_actor(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
"""The role gate applies to the acting user - separate from the supervisor
|
||||
credentials pause already requires in its own body."""
|
||||
station = await _station(db_session, admin_user.id, code="ST-ROLEPAUSE")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-ROLEPAUSE")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
supervisor, password = await _supervisor(db_session, username="capo-rolepause")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/production-runs/{run_id}/pause",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"supervisor_username": supervisor.username, "supervisor_password": password},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reading the current run - what replaces the in-page state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Tests for RateLimitMiddleware honoring proxy headers (X-Forwarded-For)."""
|
||||
import pytest
|
||||
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
from src.backend.config import settings
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
@@ -18,12 +21,33 @@ class _FakeRequest:
|
||||
self.client.host = host
|
||||
|
||||
|
||||
def test_client_ip_uses_x_forwarded_for_first_hop():
|
||||
@pytest.fixture(autouse=True)
|
||||
def default_trusted_proxy_count(monkeypatch):
|
||||
"""Every test in this file assumes the default (1 trusted proxy)."""
|
||||
monkeypatch.setattr(settings, "trusted_proxy_count", 1)
|
||||
|
||||
|
||||
def test_client_ip_uses_last_trusted_hop_of_x_forwarded_for():
|
||||
"""With one trusted proxy, the client IP is the LAST element of XFF -
|
||||
|
||||
the one appended by that proxy - not the first, which the caller supplies
|
||||
and can set to anything.
|
||||
"""
|
||||
req = _FakeRequest(
|
||||
headers={"x-forwarded-for": "203.0.113.5, 10.0.0.2"},
|
||||
host="10.0.0.1",
|
||||
)
|
||||
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
|
||||
assert RateLimitMiddleware._client_ip(req) == "10.0.0.2"
|
||||
|
||||
|
||||
def test_client_ip_ignores_spoofed_first_hop():
|
||||
"""Rotating the client-supplied (leftmost) part of XFF changes nothing:
|
||||
|
||||
the trusted, rightmost hop stays the same, so the bucket key is stable.
|
||||
"""
|
||||
req1 = _FakeRequest(headers={"x-forwarded-for": "1.1.1.1, 10.0.0.2"})
|
||||
req2 = _FakeRequest(headers={"x-forwarded-for": "9.9.9.9, 10.0.0.2"})
|
||||
assert RateLimitMiddleware._client_ip(req1) == RateLimitMiddleware._client_ip(req2) == "10.0.0.2"
|
||||
|
||||
|
||||
def test_client_ip_strips_whitespace():
|
||||
@@ -63,3 +87,93 @@ def test_empty_x_forwarded_for_falls_through():
|
||||
host="10.0.0.1",
|
||||
)
|
||||
assert RateLimitMiddleware._client_ip(req) == "203.0.113.42"
|
||||
|
||||
|
||||
def test_x_forwarded_for_shorter_than_trusted_proxy_count_falls_back(monkeypatch):
|
||||
"""Two trusted proxies configured, but XFF has only one hop: falls back to
|
||||
request.client.host rather than trusting an under-populated header."""
|
||||
monkeypatch.setattr(settings, "trusted_proxy_count", 2)
|
||||
req = _FakeRequest(
|
||||
headers={"x-forwarded-for": "203.0.113.5"},
|
||||
host="172.18.0.9",
|
||||
)
|
||||
assert RateLimitMiddleware._client_ip(req) == "172.18.0.9"
|
||||
|
||||
|
||||
def test_trusted_proxy_count_two_uses_second_to_last_hop(monkeypatch):
|
||||
monkeypatch.setattr(settings, "trusted_proxy_count", 2)
|
||||
req = _FakeRequest(
|
||||
headers={"x-forwarded-for": "1.1.1.1, 203.0.113.5, 10.0.0.2"},
|
||||
host="10.0.0.1",
|
||||
)
|
||||
assert RateLimitMiddleware._client_ip(req) == "203.0.113.5"
|
||||
|
||||
|
||||
def test_trusted_proxy_count_zero_ignores_all_proxy_headers(monkeypatch):
|
||||
monkeypatch.setattr(settings, "trusted_proxy_count", 0)
|
||||
req = _FakeRequest(
|
||||
headers={
|
||||
"x-forwarded-for": "203.0.113.5, 10.0.0.2",
|
||||
"x-real-ip": "203.0.113.99",
|
||||
},
|
||||
host="172.18.0.9",
|
||||
)
|
||||
assert RateLimitMiddleware._client_ip(req) == "172.18.0.9"
|
||||
|
||||
|
||||
class TestLoginBucketPaths:
|
||||
"""_is_login_bucket_request: which paths share the login rate-limit bucket."""
|
||||
|
||||
def test_login_path_is_login_bucket(self):
|
||||
assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/auth/login")
|
||||
|
||||
def test_login_path_get_is_not_login_bucket(self):
|
||||
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/login")
|
||||
|
||||
def test_measurement_authorise_is_login_bucket(self):
|
||||
assert RateLimitMiddleware._is_login_bucket_request(
|
||||
"POST", "/api/measurements/42/authorise"
|
||||
)
|
||||
|
||||
def test_production_pause_resume_close_are_login_bucket(self):
|
||||
for action in ("pause", "resume", "close"):
|
||||
assert RateLimitMiddleware._is_login_bucket_request(
|
||||
"POST", f"/api/production-runs/7/{action}"
|
||||
)
|
||||
|
||||
def test_production_cycle_is_not_login_bucket(self):
|
||||
"""Only the supervisor-gated actions, not every production-runs path."""
|
||||
assert not RateLimitMiddleware._is_login_bucket_request(
|
||||
"POST", "/api/production-runs/7/cycle"
|
||||
)
|
||||
|
||||
def test_setup_paths_are_login_bucket_any_method(self):
|
||||
assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/setup/init-db")
|
||||
assert RateLimitMiddleware._is_login_bucket_request("GET", "/api/setup/status")
|
||||
|
||||
def test_bare_setup_page_is_not_login_bucket(self):
|
||||
"""The HTML setup page itself carries no password."""
|
||||
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/setup")
|
||||
|
||||
def test_unrelated_path_is_not_login_bucket(self):
|
||||
assert not RateLimitMiddleware._is_login_bucket_request("POST", "/api/recipes")
|
||||
|
||||
|
||||
class TestEffectiveLimit:
|
||||
"""_effective_limit: dividing a configured limit across worker processes."""
|
||||
|
||||
def test_single_worker_keeps_configured_limit(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "web_concurrency", 1)
|
||||
assert RateLimitMiddleware._effective_limit(5) == 5
|
||||
|
||||
def test_divides_and_rounds_up(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "web_concurrency", 4)
|
||||
assert RateLimitMiddleware._effective_limit(5) == 2 # ceil(5/4)
|
||||
|
||||
def test_never_below_one(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "web_concurrency", 100)
|
||||
assert RateLimitMiddleware._effective_limit(5) == 1
|
||||
|
||||
def test_non_positive_concurrency_treated_as_one(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "web_concurrency", 0)
|
||||
assert RateLimitMiddleware._effective_limit(5) == 5
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for POST /api/recipes/{id}/duplicate (#52)."""
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
class TestDuplicateRecipe:
|
||||
async def test_duplicate_copies_tasks_and_keeps_original(
|
||||
self, client: AsyncClient, maker_user: User, db_session: AsyncSession
|
||||
):
|
||||
src = await create_test_recipe(db_session, maker_user.id)
|
||||
src_id = src.id
|
||||
before_tasks = (await db_session.execute(select(func.count(RecipeTask.id)))).scalar_one()
|
||||
before_subs = (await db_session.execute(select(func.count(RecipeSubtask.id)))).scalar_one()
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{src_id}/duplicate",
|
||||
json={"code": "REC-COPIA", "name": "Copia"},
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["code"] == "REC-COPIA" and data["name"] == "Copia"
|
||||
assert data["id"] != src_id
|
||||
assert data["description"] == "A recipe for testing"
|
||||
assert data["current_version"]["version_number"] == 1
|
||||
|
||||
# Tasks and subtasks doubled: copied, not moved
|
||||
after_tasks = (await db_session.execute(select(func.count(RecipeTask.id)))).scalar_one()
|
||||
after_subs = (await db_session.execute(select(func.count(RecipeSubtask.id)))).scalar_one()
|
||||
assert after_tasks == before_tasks * 2
|
||||
assert after_subs == before_subs * 2
|
||||
|
||||
new_version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == data["id"])
|
||||
)).scalar_one()
|
||||
new_tasks = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.version_id == new_version.id)
|
||||
)).scalars().all()
|
||||
assert len(new_tasks) == before_tasks
|
||||
|
||||
# Original unchanged
|
||||
orig = (await db_session.execute(select(Recipe).where(Recipe.id == src_id))).scalar_one()
|
||||
assert orig.code == "REC-001" and orig.name == "Test Recipe"
|
||||
orig_versions = (await db_session.execute(
|
||||
select(func.count(RecipeVersion.id)).where(RecipeVersion.recipe_id == src_id)
|
||||
)).scalar_one()
|
||||
assert orig_versions == 1
|
||||
|
||||
async def test_duplicate_code_conflict(
|
||||
self, client: AsyncClient, maker_user: User, db_session: AsyncSession
|
||||
):
|
||||
src = await create_test_recipe(db_session, maker_user.id)
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{src.id}/duplicate",
|
||||
json={"code": "REC-001", "name": "X"},
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_duplicate_missing_recipe(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/recipes/99999/duplicate",
|
||||
json={"code": "NEW", "name": "X"},
|
||||
headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Unit test for recipe_service.update_current_version (gitea#36 point 5).
|
||||
|
||||
The default task created to hold a drawing when a version has no tasks yet
|
||||
must be task_type="drawing" (as create_new_version already does at line
|
||||
~293), not the "note" default — otherwise the operator screen treats a
|
||||
version with only a drawing as an empty note.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.api.recipe import RecipeUpdate
|
||||
from src.backend.models.orm.recipe import Recipe, RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
from src.backend.services import recipe_service
|
||||
|
||||
|
||||
async def test_default_task_from_update_current_version_is_drawing_type(
|
||||
db_session: AsyncSession, admin_user,
|
||||
):
|
||||
recipe = Recipe(
|
||||
code="DRAW-425", name="Drawing only", created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(recipe)
|
||||
await db_session.flush()
|
||||
|
||||
version = RecipeVersion(
|
||||
recipe_id=recipe.id, version_number=1, is_current=True,
|
||||
created_by=admin_user.id,
|
||||
)
|
||||
db_session.add(version)
|
||||
await db_session.flush()
|
||||
# No RecipeTask yet: update_current_version must create the default one.
|
||||
|
||||
await recipe_service.update_current_version(
|
||||
db_session, recipe.id, RecipeUpdate(file_path="1/1/drawing.png"),
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
result = await db_session.execute(
|
||||
RecipeTask.__table__.select().where(RecipeTask.version_id == version.id)
|
||||
)
|
||||
task = result.mappings().one()
|
||||
assert task["task_type"] == "drawing"
|
||||
@@ -250,6 +250,79 @@ class TestUpdateRecipe:
|
||||
assert version_numbers == [1, 2]
|
||||
|
||||
|
||||
class TestUpdateRecipeClearNullableFields:
|
||||
"""gitea#37.3: measurement_interval_minutes/image_path, once set, could
|
||||
never be cleared again - the request set them and the update endpoint
|
||||
could not tell "not sent" apart from "sent as null" (both landed on the
|
||||
same getattr(...) is not None check in recipe_service._header_fields)."""
|
||||
|
||||
async def test_explicit_null_clears_measurement_interval(
|
||||
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
|
||||
set_resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"measurement_interval_minutes": 30},
|
||||
)
|
||||
assert set_resp.status_code == 200
|
||||
assert set_resp.json()["measurement_interval_minutes"] == 30
|
||||
|
||||
clear_resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"measurement_interval_minutes": None},
|
||||
)
|
||||
assert clear_resp.status_code == 200
|
||||
assert clear_resp.json()["measurement_interval_minutes"] is None
|
||||
|
||||
async def test_explicit_null_clears_image_path(
|
||||
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
|
||||
set_resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"image_path": "uploads/1/1/preview.png"},
|
||||
)
|
||||
assert set_resp.status_code == 200
|
||||
assert set_resp.json()["image_path"] == "uploads/1/1/preview.png"
|
||||
|
||||
clear_resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"image_path": None},
|
||||
)
|
||||
assert clear_resp.status_code == 200
|
||||
assert clear_resp.json()["image_path"] is None
|
||||
|
||||
async def test_field_not_sent_leaves_existing_value_untouched(
|
||||
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
|
||||
):
|
||||
"""Regression guard for the other half of the distinction: a request
|
||||
that never mentions the field must still leave it as it was."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
|
||||
set_resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"measurement_interval_minutes": 45},
|
||||
)
|
||||
assert set_resp.status_code == 200
|
||||
assert set_resp.json()["measurement_interval_minutes"] == 45
|
||||
|
||||
unrelated_resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"name": "Renamed Recipe"},
|
||||
)
|
||||
assert unrelated_resp.status_code == 200
|
||||
assert unrelated_resp.json()["measurement_interval_minutes"] == 45
|
||||
assert unrelated_resp.json()["name"] == "Renamed Recipe"
|
||||
|
||||
|
||||
class TestDeleteRecipe:
|
||||
"""DELETE /api/recipes/{recipe_id} tests."""
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Router-level test for gitea#36 point 7: recipes.py translates
|
||||
ai_service.AIResponseError to 502, not the config-error 400.
|
||||
|
||||
A dedicated file (not test_recipes.py) to avoid colliding with concurrent
|
||||
work on the copy-on-write recipe logic in that shared test module.
|
||||
"""
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import ai_service
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def test_ai_response_error_is_502(
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
db_session: AsyncSession,
|
||||
monkeypatch,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="AI-PARSE-502")
|
||||
|
||||
async def _raise_ai_response_error(_pdf_bytes):
|
||||
raise ai_service.AIResponseError("Risposta del modello AI non e' JSON valido: boom")
|
||||
|
||||
monkeypatch.setattr(ai_service, "parse_technical_sheet", _raise_ai_response_error)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{recipe.id}/parse-technical-sheet",
|
||||
headers=auth_headers(maker_user),
|
||||
files={"file": ("sheet.pdf", b"%PDF-1.4 fake", "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 502
|
||||
@@ -101,6 +101,55 @@ class TestRateLimiting:
|
||||
retry_val = int(resp.headers["retry-after"])
|
||||
assert retry_val >= 1
|
||||
|
||||
async def test_login_rate_limit_survives_rotated_x_forwarded_for(
|
||||
self, client: AsyncClient
|
||||
):
|
||||
"""A client rotating the spoofable (first) hop of XFF cannot dodge the
|
||||
login rate limit: with one trusted proxy (the default), the real
|
||||
client IP is the last hop, which stays constant."""
|
||||
for i in range(5):
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": f"rotated{i}", "password": "pass"},
|
||||
headers={
|
||||
"X-Forwarded-For": f"{i}.{i}.{i}.{i}, 198.51.100.42",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "rotated-overflow", "password": "pass"},
|
||||
headers={"X-Forwarded-For": "9.9.9.9, 198.51.100.42"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
|
||||
async def test_production_run_pause_shares_login_bucket(self, client: AsyncClient):
|
||||
"""Repeated wrong-password POSTs to a supervisor-gated action are rate
|
||||
limited under the login bucket, not the (much higher) general one."""
|
||||
for i in range(5):
|
||||
resp = await client.post(
|
||||
f"/api/production-runs/{i + 1}/pause",
|
||||
json={
|
||||
"supervisor_username": "capoturno",
|
||||
"supervisor_password": "wrong",
|
||||
"note": None,
|
||||
},
|
||||
)
|
||||
# Unauthenticated (no X-API-Key) or run not found either way -
|
||||
# what matters here is that it is not yet rate limited.
|
||||
assert resp.status_code != 429
|
||||
|
||||
resp = await client.post(
|
||||
"/api/production-runs/999/pause",
|
||||
json={
|
||||
"supervisor_username": "capoturno",
|
||||
"supervisor_password": "wrong",
|
||||
"note": None,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
|
||||
|
||||
class TestPathTraversal:
|
||||
"""Path traversal protection in file endpoints."""
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Tests for scripts/seed_collaudo.py (issue #17).
|
||||
|
||||
The script is not a package module (it is copied into the server container
|
||||
and run directly), so it is loaded here via importlib from its file path.
|
||||
"""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services.auth_service import create_user
|
||||
from src.backend.tests.conftest import TestSessionFactory, test_engine
|
||||
|
||||
_SCRIPT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "seed_collaudo.py"
|
||||
|
||||
|
||||
def _load_script():
|
||||
spec = importlib.util.spec_from_file_location("seed_collaudo", _SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_collaudo(monkeypatch):
|
||||
module = _load_script()
|
||||
monkeypatch.setattr(module, "async_session_factory", TestSessionFactory)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_app_env(monkeypatch):
|
||||
"""Every test in this file assumes the default (dev)."""
|
||||
monkeypatch.setattr(settings, "app_env", "dev")
|
||||
|
||||
|
||||
async def _make_admin_and_station(db_session) -> None:
|
||||
admin = await create_user(
|
||||
db_session, username="owner", password="OwnerPass1",
|
||||
display_name="Owner", is_admin=True,
|
||||
)
|
||||
db_session.add(Station(
|
||||
code="ST-DEFAULT", name="Default", created_by=admin.id,
|
||||
))
|
||||
await db_session.flush()
|
||||
|
||||
|
||||
class TestReplaceRequiresSi:
|
||||
"""--replace without --si only reports the impact, never deletes (#17c)."""
|
||||
|
||||
async def test_replace_without_si_does_not_delete(self, seed_collaudo, db_session):
|
||||
await _make_admin_and_station(db_session)
|
||||
|
||||
# First run creates COLLAUDO-A/B.
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True)
|
||||
assert rc == 0
|
||||
|
||||
# Second run with --replace but no --si: must not touch anything.
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", True, "SecondPass123", False)
|
||||
assert rc == 1
|
||||
|
||||
recipes = (await db_session.execute(
|
||||
select(seed_collaudo.Recipe).where(
|
||||
seed_collaudo.Recipe.code.in_(seed_collaudo.CODES)
|
||||
)
|
||||
)).scalars().all()
|
||||
assert len(recipes) == 2
|
||||
|
||||
async def test_replace_with_si_deletes_and_recreates(self, seed_collaudo, db_session):
|
||||
await _make_admin_and_station(db_session)
|
||||
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True)
|
||||
assert rc == 0
|
||||
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", True, "SecondPass123", True)
|
||||
assert rc == 0
|
||||
|
||||
recipes = (await db_session.execute(
|
||||
select(seed_collaudo.Recipe).where(
|
||||
seed_collaudo.Recipe.code.in_(seed_collaudo.CODES)
|
||||
)
|
||||
)).scalars().all()
|
||||
assert len(recipes) == 2
|
||||
|
||||
def test_main_with_fake_argv_replace_without_si_does_not_delete(
|
||||
self, seed_collaudo, monkeypatch
|
||||
):
|
||||
"""The exact scenario from the analysis: main() with a fake argv,
|
||||
--replace but no --si, must leave the recipes untouched.
|
||||
|
||||
Plain `def`, not `async def`: main() manages its own event loop via
|
||||
asyncio.run(), which cannot be called from inside one already running
|
||||
(as an `async def` test under pytest-asyncio would have).
|
||||
"""
|
||||
async def _setup():
|
||||
async with TestSessionFactory() as session:
|
||||
await _make_admin_and_station(session)
|
||||
await session.commit()
|
||||
|
||||
asyncio.run(_setup())
|
||||
assert asyncio.run(
|
||||
seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True)
|
||||
) == 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
"sys.argv",
|
||||
["seed_collaudo.py", "--station", "ST-DEFAULT", "--replace"],
|
||||
)
|
||||
rc = seed_collaudo.main()
|
||||
assert rc == 1
|
||||
|
||||
async def _count():
|
||||
async with TestSessionFactory() as session:
|
||||
recipes = (await session.execute(
|
||||
select(seed_collaudo.Recipe).where(
|
||||
seed_collaudo.Recipe.code.in_(seed_collaudo.CODES)
|
||||
)
|
||||
)).scalars().all()
|
||||
return len(recipes)
|
||||
|
||||
assert asyncio.run(_count()) == 2
|
||||
|
||||
|
||||
class TestSupervisorPassword:
|
||||
"""No default --supervisor-password: generated and printed once (#17a)."""
|
||||
|
||||
async def test_generates_random_password_when_not_given(
|
||||
self, seed_collaudo, db_session
|
||||
):
|
||||
await _make_admin_and_station(db_session)
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", False, None, True)
|
||||
assert rc == 0
|
||||
|
||||
supervisor = (await db_session.execute(
|
||||
select(User).where(User.username == "capoturno")
|
||||
)).scalar_one_or_none()
|
||||
assert supervisor is not None
|
||||
# A real password was set (create_user always hashes something).
|
||||
assert supervisor.password_hash
|
||||
|
||||
async def test_uses_given_password_when_provided(self, seed_collaudo, db_session):
|
||||
await _make_admin_and_station(db_session)
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", False, "ExplicitPass1", True)
|
||||
assert rc == 0
|
||||
|
||||
from src.backend.services.auth_service import authenticate_user
|
||||
|
||||
user = await authenticate_user(db_session, "capoturno", "ExplicitPass1")
|
||||
assert user is not None
|
||||
|
||||
async def test_existing_supervisor_password_untouched(
|
||||
self, seed_collaudo, db_session
|
||||
):
|
||||
"""A second run (no --replace) must not need a password at all."""
|
||||
await _make_admin_and_station(db_session)
|
||||
assert await seed_collaudo.seed("ST-DEFAULT", False, "FirstPass123", True) == 0
|
||||
|
||||
# Recipes already exist, so seed() stops at the "already present"
|
||||
# check before ever looking at supervisor_password.
|
||||
rc = await seed_collaudo.seed("ST-DEFAULT", False, None, True)
|
||||
assert rc == 1
|
||||
|
||||
|
||||
class TestProductionGuard:
|
||||
"""Refuses to run against APP_ENV outside dev/test without --conferma-produzione (#17b)."""
|
||||
|
||||
def test_main_refuses_in_production_without_confirmation(
|
||||
self, seed_collaudo, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(settings, "app_env", "production")
|
||||
monkeypatch.setattr(
|
||||
"sys.argv", ["seed_collaudo.py", "--station", "ST-DEFAULT"]
|
||||
)
|
||||
rc = seed_collaudo.main()
|
||||
assert rc == 2
|
||||
|
||||
def test_main_allows_dev_without_confirmation(self, seed_collaudo, monkeypatch):
|
||||
monkeypatch.setattr(settings, "app_env", "dev")
|
||||
# No admin/station set up: seed() itself will fail with rc=2, but that
|
||||
# proves the production guard did not block it first.
|
||||
monkeypatch.setattr(
|
||||
"sys.argv", ["seed_collaudo.py", "--station", "NOPE"]
|
||||
)
|
||||
rc = seed_collaudo.main()
|
||||
assert rc == 2 # "Nessun utente amministratore" / stazione inesistente
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Tests for settings router (/api/settings)."""
|
||||
import io
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -105,3 +109,201 @@ class TestUpdateSettings:
|
||||
json={"some_key": "some_value"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestUpdateSettingsValidation:
|
||||
"""gitea#23: an invalid csv_delimiter must be rejected, not accepted and
|
||||
left to blow up close_run later with a csv.writer TypeError."""
|
||||
|
||||
async def test_csv_delimiter_two_chars_rejected(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/settings/",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"csv_delimiter": ";;"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_csv_delimiter_empty_rejected(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/settings/",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"csv_delimiter": ""},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_csv_delimiter_tab_accepted(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/settings/",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"csv_delimiter": "\t"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_csv_decimal_separator_invalid_rejected(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/settings/",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"csv_decimal_separator": "x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_auto_logout_minutes_out_of_range_rejected(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/settings/",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"auto_logout_minutes": "0"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_auto_logout_minutes_empty_disables(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
"""Empty string is what the settings page sends to disable auto-logout."""
|
||||
resp = await client.put(
|
||||
"/api/settings/",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"auto_logout_minutes": ""},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# --- #47/#48/#49: chiavi Impostazioni di Tràfilo ---
|
||||
|
||||
@pytest.mark.parametrize("key,value", [
|
||||
("default_measurement_interval_minutes", "1"),
|
||||
("default_measurement_interval_minutes", "1440"),
|
||||
("default_measurement_interval_minutes", ""),
|
||||
("general_tolerance_plus", "0"),
|
||||
("general_tolerance_plus", "0.05"),
|
||||
("general_tolerance_minus", ""),
|
||||
("list_measurement_types", '["Lunghezza", "Diametro"]'),
|
||||
("list_units", "[]"),
|
||||
("list_units", ""),
|
||||
])
|
||||
async def test_new_keys_valid_accepted(
|
||||
self, client: AsyncClient, admin_user: User, key: str, value: str
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/settings/", headers=auth_headers(admin_user), json={key: value}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
@pytest.mark.parametrize("key,value", [
|
||||
("default_measurement_interval_minutes", "0"),
|
||||
("default_measurement_interval_minutes", "1441"),
|
||||
("default_measurement_interval_minutes", "abc"),
|
||||
("general_tolerance_plus", "-0.1"),
|
||||
("general_tolerance_plus", "0,05"),
|
||||
("general_tolerance_minus", "NaN"),
|
||||
("general_tolerance_minus", "abc"),
|
||||
("list_measurement_types", "not json"),
|
||||
("list_measurement_types", '{"a": 1}'),
|
||||
("list_units", '["mm", ""]'),
|
||||
("list_units", '["mm", 3]'),
|
||||
("list_units", "[" + ",".join(f'"v{i}"' for i in range(51)) + "]"),
|
||||
])
|
||||
async def test_new_keys_invalid_rejected(
|
||||
self, client: AsyncClient, admin_user: User, key: str, value: str
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/settings/", headers=auth_headers(admin_user), json={key: value}
|
||||
)
|
||||
assert resp.status_code == 422, resp.text
|
||||
assert key in resp.json()["detail"]
|
||||
|
||||
async def test_unknown_key_not_validated(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
"""Keys with no known validator pass through (generic settings store)."""
|
||||
resp = await client.put(
|
||||
"/api/settings/",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"some_future_key": "anything"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestUploadLogo:
|
||||
"""POST /api/settings/logo tests (gitea#36 point 2)."""
|
||||
|
||||
_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
|
||||
|
||||
async def test_valid_png_accepted(
|
||||
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
||||
):
|
||||
with patch("src.backend.api.routers.settings.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
|
||||
resp = await client.post(
|
||||
"/api/settings/logo",
|
||||
headers=auth_headers(admin_user),
|
||||
files={"file": ("logo.png", io.BytesIO(self._PNG_BYTES), "image/png")},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["logo_path"].endswith(".png")
|
||||
|
||||
async def test_svg_refused_even_with_svg_content_type(
|
||||
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
||||
):
|
||||
"""SVG is refused outright, not just when it lies about its type."""
|
||||
svg_bytes = b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
||||
|
||||
with patch("src.backend.api.routers.settings.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
|
||||
resp = await client.post(
|
||||
"/api/settings/logo",
|
||||
headers=auth_headers(admin_user),
|
||||
files={"file": ("logo.svg", io.BytesIO(svg_bytes), "image/svg+xml")},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert not any(tmp_path.rglob("*.svg"))
|
||||
|
||||
async def test_pdf_refused_for_logo(
|
||||
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
||||
):
|
||||
"""The logo slot only accepts images: files.py's PDF signature must
|
||||
not slip through settings.py's narrower ALLOWED_IMAGE_TYPES check."""
|
||||
pdf_bytes = b"%PDF-1.4\n" + b"\x00" * 20
|
||||
|
||||
with patch("src.backend.api.routers.settings.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
|
||||
resp = await client.post(
|
||||
"/api/settings/logo",
|
||||
headers=auth_headers(admin_user),
|
||||
files={"file": ("logo.pdf", io.BytesIO(pdf_bytes), "application/pdf")},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
async def test_html_disguised_as_png_refused(
|
||||
self, client: AsyncClient, admin_user: User, tmp_path: Path
|
||||
):
|
||||
html_bytes = b"<html><body><script>alert(1)</script></body></html>"
|
||||
|
||||
with patch("src.backend.api.routers.settings.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
|
||||
resp = await client.post(
|
||||
"/api/settings/logo",
|
||||
headers=auth_headers(admin_user),
|
||||
files={"file": ("logo.png", io.BytesIO(html_bytes), "image/png")},
|
||||
)
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -23,6 +23,11 @@ def enable_setup(monkeypatch):
|
||||
import src.backend.api.routers.setup as setup_mod
|
||||
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||
# _stamp_head runs `alembic stamp head`, which builds its own connection
|
||||
# from settings.database_url via migrations/env.py - the real MySQL URL,
|
||||
# not the patched test engine above. No-op it here so init-db/reset-db
|
||||
# tests never try to reach a MySQL server that does not exist in CI/dev.
|
||||
monkeypatch.setattr(setup_mod, "_stamp_head", lambda: None)
|
||||
|
||||
|
||||
async def _seed(client: AsyncClient) -> dict:
|
||||
@@ -157,9 +162,12 @@ async def test_create_user(client: AsyncClient):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_duplicate_username(client: AsyncClient):
|
||||
async def test_create_user_duplicate_username(client: AsyncClient, monkeypatch):
|
||||
"""Creating a user with a duplicate username should return 409."""
|
||||
await _seed(client)
|
||||
# _seed already created an admin; bypass the "setup already completed"
|
||||
# guard (#9) so this test can still exercise manage-user's own checks.
|
||||
monkeypatch.setattr(settings, "setup_allow_after_init", True)
|
||||
|
||||
resp = await client.post("/api/setup/manage-user", json={
|
||||
"password": SETUP_PWD,
|
||||
@@ -188,9 +196,10 @@ async def test_create_user_without_password(client: AsyncClient):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user(client: AsyncClient):
|
||||
async def test_update_user(client: AsyncClient, monkeypatch):
|
||||
"""Update an existing user via manage-user endpoint."""
|
||||
await _seed(client)
|
||||
monkeypatch.setattr(settings, "setup_allow_after_init", True)
|
||||
|
||||
# Get users to find the ID
|
||||
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
|
||||
@@ -232,9 +241,10 @@ async def test_create_user_invalid_role(client: AsyncClient):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_change_password(client: AsyncClient):
|
||||
async def test_change_password(client: AsyncClient, monkeypatch):
|
||||
"""Changing password should succeed and invalidate api_key."""
|
||||
await _seed(client)
|
||||
monkeypatch.setattr(settings, "setup_allow_after_init", True)
|
||||
|
||||
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
|
||||
users = resp.json()["users"]
|
||||
@@ -292,3 +302,159 @@ async def test_toggle_user_active(client: AsyncClient):
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["active"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 3: _check_password is timing-safe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_password_still_rejected_with_compare_digest(client: AsyncClient):
|
||||
"""hmac.compare_digest must not change the pass/fail outcome."""
|
||||
resp = await client.post("/api/setup/list-users", json={"password": "definitely-wrong"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_correct_password_still_accepted_with_compare_digest(client: AsyncClient):
|
||||
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 4: setup already completed - privileged endpoints blocked (#9)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_privileged_endpoints_blocked_once_admin_exists(client: AsyncClient, monkeypatch):
|
||||
"""reset-db, seed, manage-user, change-user-password, init-db all 403
|
||||
once an admin exists, unless setup_allow_after_init is set."""
|
||||
# This test makes more than rate_limit_login (5/min) requests to
|
||||
# /api/setup/* on purpose, to exercise every guarded endpoint - raise the
|
||||
# limit so it is the #9 guard being tested, not the #8/#9(d) rate limit.
|
||||
monkeypatch.setattr(settings, "rate_limit_login", 100)
|
||||
data = await _seed(client) # creates the "admin" user (is_admin=True)
|
||||
admin_id = None
|
||||
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
|
||||
for u in resp.json()["users"]:
|
||||
if u["username"] == "admin":
|
||||
admin_id = u["id"]
|
||||
assert admin_id is not None
|
||||
|
||||
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 403
|
||||
assert "already completed" in resp.json()["detail"]
|
||||
|
||||
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 403
|
||||
|
||||
resp = await client.post("/api/setup/manage-user", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": None,
|
||||
"username": "blocked",
|
||||
"user_password": "pass123",
|
||||
"display_name": "Blocked",
|
||||
"roles": [],
|
||||
})
|
||||
assert resp.status_code == 403
|
||||
|
||||
resp = await client.post("/api/setup/change-user-password", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": admin_id,
|
||||
"new_password": "whatever123",
|
||||
})
|
||||
assert resp.status_code == 403
|
||||
|
||||
resp = await client.post("/api/setup/reset-db", json={
|
||||
"password": SETUP_PWD,
|
||||
"confirm": True,
|
||||
})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_privileged_endpoints_allowed_before_any_admin(client: AsyncClient):
|
||||
"""On a fresh setup (no admin yet), init-db and seed still work."""
|
||||
resp = await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200
|
||||
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_allow_after_init_overrides_guard(client: AsyncClient, monkeypatch):
|
||||
"""SETUP_ALLOW_AFTER_INIT=true restores the old (pre-#9) behaviour."""
|
||||
await _seed(client)
|
||||
monkeypatch.setattr(settings, "setup_allow_after_init", True)
|
||||
|
||||
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_user_active_not_guarded(client: AsyncClient):
|
||||
"""toggle-user-active is not in the guarded list, admin or not."""
|
||||
await _seed(client)
|
||||
resp = await client.post("/api/setup/list-users", json={"password": SETUP_PWD})
|
||||
maker = next(u for u in resp.json()["users"] if u["username"] == "maker1")
|
||||
|
||||
resp = await client.post("/api/setup/toggle-user-active", json={
|
||||
"password": SETUP_PWD,
|
||||
"user_id": maker["id"],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_admin_not_guarded(client: AsyncClient):
|
||||
"""create-admin is not in the guarded list either (see analysis of #9)."""
|
||||
await _seed(client)
|
||||
resp = await client.post("/api/setup/create-admin", json={
|
||||
"password": SETUP_PWD,
|
||||
"admin_username": "second_admin",
|
||||
"admin_password": "pass12345",
|
||||
"admin_email": "second@test.com",
|
||||
"admin_display_name": "Second Admin",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature 5: GET /api/setup/status without/with password (#9)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_without_password_is_minimal(client: AsyncClient):
|
||||
resp = await client.get("/api/setup/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"initialised": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_with_wrong_password_is_minimal(client: AsyncClient):
|
||||
resp = await client.get("/api/setup/status", params={"password": "nope"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"initialised": False}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_with_valid_password_lists_tables(client: AsyncClient):
|
||||
await client.post("/api/setup/init-db", json={"password": SETUP_PWD})
|
||||
resp = await client.get("/api/setup/status", params={"password": SETUP_PWD})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "tables" in data
|
||||
assert "table_count" in data
|
||||
assert data["tables_exist"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_initialised_true_after_seed(client: AsyncClient):
|
||||
await _seed(client)
|
||||
resp = await client.get("/api/setup/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"initialised": True}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Unit tests for spc_service.py: Cp/Cpk (within, moving-range) vs Pp/Ppk (overall).
|
||||
|
||||
Expected values are computed in each test from the formulas themselves
|
||||
(not copied from the implementation's output), so a regression in the
|
||||
formula would also break the test's own expectation derivation... except
|
||||
we compute them independently here with plain stdlib code, mirroring the
|
||||
spec in gitea:Adriano/TieMeasureFlow#19.
|
||||
"""
|
||||
import statistics as stats
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from src.backend.services.spc_service import compute_capability, compute_control_chart
|
||||
|
||||
|
||||
def _expected_sigma_within(values: list[float]) -> float | None:
|
||||
"""Independent re-implementation of MRbar/1.128 for test expectations."""
|
||||
if len(values) < 2:
|
||||
return None
|
||||
mr = [abs(values[i] - values[i - 1]) for i in range(1, len(values))]
|
||||
mr_bar = stats.mean(mr)
|
||||
if mr_bar == 0:
|
||||
return None
|
||||
return mr_bar / 1.128
|
||||
|
||||
|
||||
# 13 values around 10.00, then 12 values around 10.08: a level shift that
|
||||
# a global (overall) std dev smears, but that stays invisible to the
|
||||
# consecutive-point moving range within each stable block.
|
||||
_SHIFTED_SERIES = (
|
||||
[10.00, 10.01, 9.99, 10.02, 9.98, 10.01, 9.99, 10.00, 10.02, 9.98, 10.01, 9.99, 10.00]
|
||||
+ [10.08, 10.09, 10.07, 10.08, 10.09, 10.07, 10.08, 10.09, 10.07, 10.08, 10.09, 10.07]
|
||||
)
|
||||
|
||||
|
||||
class TestComputeCapability:
|
||||
def test_cp_uses_sigma_within_pp_uses_overall_sample_stdev(self):
|
||||
utl, ltl = 10.2, 9.8
|
||||
result = compute_capability(_SHIFTED_SERIES, utl, ltl, nominal=10.0)
|
||||
|
||||
sample_std = stats.stdev(_SHIFTED_SERIES)
|
||||
sigma_within = _expected_sigma_within(_SHIFTED_SERIES)
|
||||
assert sigma_within is not None
|
||||
|
||||
expected_cp = round((utl - ltl) / (6 * sigma_within), 4)
|
||||
expected_pp = round((utl - ltl) / (6 * sample_std), 4)
|
||||
|
||||
assert result.cp == expected_cp
|
||||
assert result.pp == expected_pp
|
||||
# Cp (within, tight) must differ from Pp (overall, widened by the
|
||||
# level shift) — this is exactly the drift #19 says must surface.
|
||||
assert result.cp != result.pp
|
||||
assert result.cp > result.pp
|
||||
assert result.std_dev == round(sample_std, 6)
|
||||
assert result.std_dev_within == round(sigma_within, 6)
|
||||
|
||||
def test_constant_series_returns_none_indices(self):
|
||||
values = [10.0] * 10
|
||||
result = compute_capability(values, 10.2, 9.8, 10.0)
|
||||
assert result.cp is None
|
||||
assert result.cpk is None
|
||||
assert result.pp is None
|
||||
assert result.ppk is None
|
||||
assert result.std_dev_within is None
|
||||
|
||||
def test_n_less_than_2_returns_none(self):
|
||||
result = compute_capability([10.0], 10.2, 9.8, 10.0)
|
||||
assert result.cp is None
|
||||
assert result.cpk is None
|
||||
assert result.pp is None
|
||||
assert result.ppk is None
|
||||
assert result.n == 1
|
||||
|
||||
def test_one_sided_tolerance_only_cpk(self):
|
||||
values = [10.0, 10.01, 9.99, 10.02, 9.98, 10.01, 9.99, 10.0]
|
||||
result = compute_capability(values, utl=10.2, ltl=None, nominal=10.0)
|
||||
assert result.cp is None # both UTL and LTL required
|
||||
assert result.pp is None
|
||||
assert result.cpk is not None
|
||||
assert result.ppk is not None
|
||||
|
||||
|
||||
class TestComputeControlChart:
|
||||
def test_ucl_lcl_use_sigma_within_not_overall_stdev(self):
|
||||
base = datetime(2026, 1, 1)
|
||||
timestamps = [base + timedelta(minutes=i) for i in range(len(_SHIFTED_SERIES))]
|
||||
|
||||
result = compute_control_chart(
|
||||
_SHIFTED_SERIES, timestamps, 10.2, None, None, 9.8, 10.0
|
||||
)
|
||||
|
||||
mean = stats.mean(_SHIFTED_SERIES)
|
||||
sigma_within = _expected_sigma_within(_SHIFTED_SERIES)
|
||||
expected_ucl = round(mean + 3 * sigma_within, 6)
|
||||
expected_lcl = round(mean - 3 * sigma_within, 6)
|
||||
|
||||
assert result.ucl == expected_ucl
|
||||
assert result.lcl == expected_lcl
|
||||
|
||||
# Points beyond the within-sigma limits must be flagged: the level
|
||||
# shift is detectable this way but was invisible with the overall
|
||||
# sample std dev (limits too wide to ever trip).
|
||||
assert result.out_of_control != []
|
||||
overall_std = stats.stdev(_SHIFTED_SERIES)
|
||||
overall_ucl = mean + 3 * overall_std
|
||||
overall_lcl = mean - 3 * overall_std
|
||||
overall_ooc = [
|
||||
i for i, v in enumerate(_SHIFTED_SERIES) if v > overall_ucl or v < overall_lcl
|
||||
]
|
||||
assert overall_ooc == []
|
||||
|
||||
def test_constant_series_zero_limits(self):
|
||||
values = [10.0] * 6
|
||||
timestamps = [datetime(2026, 1, 1) + timedelta(minutes=i) for i in range(6)]
|
||||
result = compute_control_chart(values, timestamps, 10.2, None, None, 9.8, 10.0)
|
||||
assert result.ucl == 10.0
|
||||
assert result.lcl == 10.0
|
||||
assert result.out_of_control == []
|
||||
|
||||
def test_single_value_zero_limits(self):
|
||||
timestamps = [datetime(2026, 1, 1)]
|
||||
result = compute_control_chart([10.0], timestamps, 10.2, None, None, 9.8, 10.0)
|
||||
assert result.ucl == 10.0
|
||||
assert result.lcl == 10.0
|
||||
@@ -34,6 +34,9 @@ def enable_setup(monkeypatch):
|
||||
import src.backend.api.routers.setup as setup_mod
|
||||
monkeypatch.setattr(setup_mod, "engine", test_engine)
|
||||
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
|
||||
# See test_setup.py: _stamp_head would otherwise try to reach a real
|
||||
# MySQL server via migrations/env.py.
|
||||
monkeypatch.setattr(setup_mod, "_stamp_head", lambda: None)
|
||||
|
||||
|
||||
async def _seed(client: AsyncClient) -> dict:
|
||||
@@ -83,11 +86,17 @@ async def test_setup_seed_creates_default_station_and_assigns_recipes(
|
||||
async def test_setup_seed_station_idempotent(
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Running seed twice must not duplicate ST-DEFAULT or its assignments."""
|
||||
# First run — creates everything.
|
||||
await _seed(client)
|
||||
|
||||
# Second run: the first seed already created an admin, so this exercises
|
||||
# setup's own "setup already completed" guard (#9) unless bypassed - the
|
||||
# idempotency this test is about is seed's, not the guard's.
|
||||
monkeypatch.setattr(settings, "setup_allow_after_init", True)
|
||||
|
||||
# Second run — recipe DEMO-001 already exists → seed returns early.
|
||||
# ST-DEFAULT must still exist (not re-created, not duplicated).
|
||||
resp = await client.post("/api/setup/seed", json={"password": SETUP_PWD})
|
||||
|
||||
@@ -211,3 +211,79 @@ class TestDateFilter:
|
||||
assert resp.status_code == 200
|
||||
# All measurements should be within this wide range
|
||||
assert resp.json()["total"] >= 2
|
||||
|
||||
|
||||
class TestRunFilter:
|
||||
"""#58: statistiche per sessione di produzione."""
|
||||
|
||||
async def _setup(self, db_session, user, metro):
|
||||
from datetime import datetime
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from sqlalchemy import select
|
||||
recipe = await create_test_recipe(db_session, user.id)
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().first()
|
||||
station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
runs = []
|
||||
for i, lot in enumerate(["L1", "L2"]):
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=user.id, status="closed", lot_number=lot,
|
||||
started_at=datetime(2026, 1, 1 + i),
|
||||
)
|
||||
db_session.add(run)
|
||||
runs.append(run)
|
||||
await db_session.commit()
|
||||
return recipe, runs
|
||||
|
||||
async def test_filter_by_run_and_unfiltered(
|
||||
self, client, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user)
|
||||
subtask_id, version_id = await _seed_measurements(
|
||||
client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2],
|
||||
)
|
||||
# Assegna le prime due misure al run 1, la terza al run 2.
|
||||
from sqlalchemy import select
|
||||
ms = (await db_session.execute(
|
||||
select(Measurement).order_by(Measurement.id)
|
||||
)).scalars().all()
|
||||
ms[0].production_run_id = runs[0].id
|
||||
ms[1].production_run_id = runs[0].id
|
||||
ms[2].production_run_id = runs[1].id
|
||||
await db_session.commit()
|
||||
|
||||
h = auth_headers(metrologist_user)
|
||||
r1 = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id, "production_run_id": runs[0].id})
|
||||
assert r1.json()["total"] == 2
|
||||
r2 = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id, "production_run_id": runs[1].id})
|
||||
assert r2.json()["total"] == 1
|
||||
r_all = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id})
|
||||
assert r_all.json()["total"] == 3
|
||||
|
||||
runs_resp = await client.get("/api/statistics/runs", headers=h,
|
||||
params={"recipe_id": recipe.id})
|
||||
assert runs_resp.status_code == 200
|
||||
data = runs_resp.json()
|
||||
assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima
|
||||
assert [r["measurement_count"] for r in data] == [1, 2]
|
||||
assert data[0]["lot_number"] == "L2"
|
||||
assert data[0]["station_name"] == "Stazione 58"
|
||||
assert data[0]["closed_at"] is None
|
||||
|
||||
async def test_runs_require_metrologist(
|
||||
self, client, measurement_tec_user, db_session,
|
||||
):
|
||||
resp = await client.get("/api/statistics/runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
params={"recipe_id": 1})
|
||||
assert resp.status_code == 403
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""#43: reference files removed with their task, animated images refused,
|
||||
GET /api/measurements filtered by production run."""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.vision import VisionReferenceImage
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.tests.test_vision_upload_limits import a_png, recipe_parts
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def redirect_uploads(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||
|
||||
|
||||
async def _upload_reference(client, user, task_id):
|
||||
resp = await client.post(
|
||||
"/api/vision/reference-images", headers=auth_headers(user),
|
||||
files={"image": ("p.png", a_png(), "image/png")},
|
||||
data={"task_id": str(task_id)},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_task_removes_the_reference_file(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||
await db_session.commit()
|
||||
await _upload_reference(client, maker_user, task.id)
|
||||
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||
path = settings.upload_path / ref.path
|
||||
assert path.exists()
|
||||
|
||||
resp = await client.delete(f"/api/tasks/{task.id}", headers=auth_headers(maker_user))
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_task_keeps_a_file_another_task_still_references(
|
||||
client, db_session, maker_user,
|
||||
):
|
||||
r1 = await create_test_recipe(db_session, maker_user.id)
|
||||
r2 = await create_test_recipe(db_session, maker_user.id, code="REC-002")
|
||||
_, t1, _ = await recipe_parts(db_session, r1.id)
|
||||
_, t2, _ = await recipe_parts(db_session, r2.id)
|
||||
await db_session.commit()
|
||||
await _upload_reference(client, maker_user, t1.id)
|
||||
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||
db_session.add(VisionReferenceImage(task_id=t2.id, path=ref.path))
|
||||
await db_session.commit()
|
||||
path = settings.upload_path / ref.path
|
||||
|
||||
resp = await client.delete(f"/api/tasks/{t1.id}", headers=auth_headers(maker_user))
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def _animated(fmt: str, frames: int) -> bytes:
|
||||
imgs = [Image.new("L", (16, 16), color=i * 40) for i in range(frames)]
|
||||
buf = io.BytesIO()
|
||||
imgs[0].save(buf, format=fmt, save_all=True, append_images=imgs[1:], duration=50)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("fmt,mime,frames,expected", [
|
||||
("GIF", "image/gif", 2, 422),
|
||||
("WEBP", "image/webp", 2, 422),
|
||||
("GIF", "image/gif", 1, 201),
|
||||
])
|
||||
async def test_animated_images_are_refused(
|
||||
client, db_session, maker_user, fmt, mime, frames, expected,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/vision/reference-images", headers=auth_headers(maker_user),
|
||||
files={"image": ("a", _animated(fmt, frames), mime)},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
if expected == 422:
|
||||
assert resp.status_code == 422
|
||||
assert "animated" in resp.json()["detail"]
|
||||
else:
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_measurements_filter_by_production_run(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
version, _, subtask = await recipe_parts(db_session, recipe.id)
|
||||
for run in (7, 7, 8, None):
|
||||
db_session.add(Measurement(
|
||||
subtask_id=subtask.id, version_id=version.id, measured_by=maker_user.id,
|
||||
value=1.0, pass_fail="pass", deviation=0.0, input_method="manual",
|
||||
production_run_id=run,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get(
|
||||
"/api/measurements/", headers=auth_headers(maker_user),
|
||||
params={"production_run_id": 7},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
assert {m["production_run_id"] for m in body["items"]} == {7}
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Test di in_loop / at_close / fields e del tipo 'check' sui task (#53 #54 #55),
|
||||
di close_task_ids nella risposta del run e del bucket di rate limit del login PIN."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
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.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
FIELDS = [
|
||||
{"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False},
|
||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||
]
|
||||
|
||||
|
||||
async def _post_task(client, user, recipe_id, **body):
|
||||
return await client.post(
|
||||
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user), json=body,
|
||||
)
|
||||
|
||||
|
||||
async def test_create_defaults_in_loop_from_type(client: AsyncClient, maker_user, db_session):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L1")
|
||||
await db_session.commit()
|
||||
expected = {"note": False, "drawing": False, "measure": True,
|
||||
"camera_measure": True, "dxf_compare": True, "check": True}
|
||||
for task_type, in_loop in expected.items():
|
||||
resp = await _post_task(client, maker_user, recipe.id, title=task_type, task_type=task_type)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["in_loop"] is in_loop, task_type
|
||||
assert body["at_close"] is False
|
||||
assert body["fields"] is None
|
||||
|
||||
|
||||
async def test_create_explicit_in_loop_and_note_fields(client: AsyncClient, maker_user, db_session):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L2")
|
||||
await db_session.commit()
|
||||
resp = await _post_task(
|
||||
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||
in_loop=True, at_close=True, fields=FIELDS,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["in_loop"] is True and body["at_close"] is True
|
||||
assert body["fields"] == FIELDS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task_type", ["measure", "check", "drawing"])
|
||||
async def test_create_at_close_or_fields_rejected_for_other_types(
|
||||
client: AsyncClient, maker_user, db_session, task_type,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=f"REC-L3{task_type}")
|
||||
await db_session.commit()
|
||||
resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, at_close=True)
|
||||
assert resp.status_code == 422
|
||||
assert isinstance(resp.json()["detail"], str)
|
||||
resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, fields=FIELDS)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
[{"key": "A b", "label": "x", "type": "text"}], # chiave non slug
|
||||
[{"key": "k", "label": "", "type": "text"}], # etichetta vuota
|
||||
[{"key": "k", "label": "x", "type": "date"}], # tipo ignoto
|
||||
[{"key": "k", "label": "x", "type": "text"}, {"key": "k", "label": "y", "type": "text"}], # duplicata
|
||||
[{"key": "k", "label": "x", "type": "number", "default": "abc"}], # default non numerico
|
||||
[{"key": f"k{i}", "label": "x", "type": "text"} for i in range(21)], # troppi
|
||||
])
|
||||
async def test_create_invalid_fields_rejected(client: AsyncClient, maker_user, db_session, bad):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L4")
|
||||
await db_session.commit()
|
||||
resp = await _post_task(client, maker_user, recipe.id, title="n", task_type="note", fields=bad)
|
||||
assert resp.status_code == 422
|
||||
assert isinstance(resp.json()["detail"], str)
|
||||
|
||||
|
||||
async def test_update_fields_null_clears_and_type_rules(client: AsyncClient, maker_user, db_session):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L5")
|
||||
await db_session.commit()
|
||||
created = (await _post_task(
|
||||
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||
at_close=True, fields=FIELDS,
|
||||
)).json()
|
||||
headers = auth_headers(maker_user)
|
||||
|
||||
# il frontend manda sempre i tre campi: fields null = nessun campo
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"in_loop": True, "at_close": False, "fields": None},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["in_loop"] is True and body["at_close"] is False and body["fields"] is None
|
||||
|
||||
# tipo diverso da note con at_close o fields: 422
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"task_type": "measure", "at_close": True, "fields": None},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"task_type": "check", "at_close": False, "fields": FIELDS},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
# un task che ha gia' at_close non puo' passare a measure senza toglierlo
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"at_close": True, "fields": FIELDS},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers, json={"task_type": "measure"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_copy_on_write_carries_the_three_fields(
|
||||
client: AsyncClient, maker_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L6")
|
||||
await db_session.commit()
|
||||
created = (await _post_task(
|
||||
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||
in_loop=True, at_close=True, fields=FIELDS,
|
||||
)).json()
|
||||
|
||||
# una misura sulla versione corrente forza il copy-on-write alla prossima modifica
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)).scalar_one()
|
||||
subtask_id = (await db_session.execute(
|
||||
select(RecipeSubtask.id).join(RecipeSubtask.task)
|
||||
.where(RecipeSubtask.task.has(version_id=version.id))
|
||||
)).scalars().first()
|
||||
db_session.add(Measurement(
|
||||
subtask_id=subtask_id, version_id=version.id, measured_by=maker_user.id,
|
||||
value=10.0, pass_fail="pass", deviation=0.0, input_method="manual",
|
||||
))
|
||||
await db_session.commit()
|
||||
# sessione condivisa col client: la collezione version.tasks va riletta
|
||||
db_session.expire_all()
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=auth_headers(maker_user),
|
||||
json={"title": "Nota rinominata"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["version_id"] != version.id # e' la copia nella nuova versione
|
||||
assert body["in_loop"] is True and body["at_close"] is True
|
||||
assert body["fields"] == FIELDS
|
||||
|
||||
|
||||
async def test_run_response_loop_and_close_task_ids(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L7")
|
||||
db_session.add(Station(code="ST-L7", name="S", active=True, created_by=admin_user.id))
|
||||
await db_session.commit()
|
||||
|
||||
async def mk(**body):
|
||||
resp = await _post_task(client, maker_user, recipe.id, **body)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
check = await mk(title="Check", task_type="check")
|
||||
note_loop = await mk(title="Nota ciclo", task_type="note", in_loop=True)
|
||||
note_out = await mk(title="Nota fuori", task_type="note")
|
||||
close_a = await mk(title="Chiusura A", task_type="note", at_close=True)
|
||||
measure_off = await mk(title="Misura fuori", task_type="measure", in_loop=False)
|
||||
close_b = await mk(title="Chiusura B", task_type="note", at_close=True)
|
||||
|
||||
first_id = (await db_session.execute(
|
||||
select(RecipeTask.id).where(RecipeTask.version_id == check["version_id"])
|
||||
.order_by(RecipeTask.order_index)
|
||||
)).scalars().first()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/production-runs", headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": "ST-L7", "recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
# ciclo: solo in_loop, in ordine (misura della fixture, check, nota in ciclo)
|
||||
assert body["measurement_task_ids"] == [first_id, check["id"], note_loop["id"]]
|
||||
assert note_out["id"] not in body["measurement_task_ids"]
|
||||
assert measure_off["id"] not in body["measurement_task_ids"]
|
||||
assert body["close_task_ids"] == [close_a["id"], close_b["id"]]
|
||||
|
||||
|
||||
class TestLoginPinRateLimitBucket:
|
||||
"""#59: il login con PIN sta nel bucket 'login' come il login con password."""
|
||||
|
||||
def test_login_pin_is_login_bucket(self):
|
||||
assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/auth/login-pin")
|
||||
|
||||
def test_login_pin_get_is_not_login_bucket(self):
|
||||
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/login-pin")
|
||||
|
||||
def test_pin_users_list_is_not_login_bucket(self):
|
||||
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/pin-users")
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Test di POST/GET /api/task-results (#54 check OK/NOK, #55 note con campi)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
FIELDS = [
|
||||
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
|
||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||
]
|
||||
|
||||
|
||||
async def _setup(client, db_session, maker_user, admin_user, code):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=code)
|
||||
db_session.add(Station(code=f"ST-{code}", name="S", active=True, created_by=admin_user.id))
|
||||
await db_session.commit()
|
||||
h = auth_headers(maker_user)
|
||||
check = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h, json={"title": "C", "task_type": "check"},
|
||||
)).json()
|
||||
note = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||
json={"title": "N", "task_type": "note", "fields": FIELDS},
|
||||
)).json()
|
||||
return recipe, check, note
|
||||
|
||||
|
||||
async def _open_run(client, user, recipe, code):
|
||||
resp = await client.post(
|
||||
"/api/production-runs", headers=auth_headers(user),
|
||||
json={"station_code": f"ST-{code}", "recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _post(client, user, **body):
|
||||
return await client.post("/api/task-results", headers=auth_headers(user), json=body)
|
||||
|
||||
|
||||
async def test_requires_auth_and_role(client: AsyncClient, maker_user, admin_user, db_session):
|
||||
_, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR0")
|
||||
resp = await client.post("/api/task-results", json={"task_id": check["id"], "outcome": "ok"})
|
||||
assert resp.status_code == 401
|
||||
# un Maker senza ruolo di reparto non registra esiti
|
||||
resp = await _post(client, maker_user, task_id=check["id"], outcome="ok")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_check_ok_and_nok_rules(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR1")
|
||||
run = await _open_run(client, measurement_tec_user, recipe, "TR1")
|
||||
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=run["id"], outcome="ok")
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["outcome"] == "ok"
|
||||
assert body["version_id"] == check["version_id"]
|
||||
assert body["user_id"] == measurement_tec_user.id
|
||||
assert body["production_run_id"] == run["id"]
|
||||
assert body["created_at"]
|
||||
|
||||
for note in (None, "", " "):
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
outcome="nok", note=note)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"] == "Per un NOK serve la nota"
|
||||
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
outcome="nok", note=" graffio ")
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["note"] == "graffio"
|
||||
|
||||
# un check non si chiude con 'done'
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"], outcome="done")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_note_values_and_attachments(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, _, note = await _setup(client, db_session, maker_user, admin_user, "TR2")
|
||||
ok = await _post(
|
||||
client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
values={"peso": "12,5"},
|
||||
attachments=[{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}],
|
||||
)
|
||||
assert ok.status_code == 201, ok.text
|
||||
assert ok.json()["values"] == {"peso": "12,5"}
|
||||
assert ok.json()["attachments"] == [{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}]
|
||||
|
||||
# solo done
|
||||
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="ok")).status_code == 422
|
||||
# chiave fuori dai campi
|
||||
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
values={"altro": "1"})).status_code == 422
|
||||
# numero non convertibile
|
||||
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
values={"peso": "abc"})).status_code == 422
|
||||
# allegati con percorso non sicuro
|
||||
for bad in ("../x.jpg", "/etc/passwd", "a/../b.jpg", "C:/x.jpg", "a\\b.jpg"):
|
||||
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
attachments=[{"key": None, "path": bad, "name": "x"}])
|
||||
assert resp.status_code == 422, bad
|
||||
# chiave allegato fuori dai campi
|
||||
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
attachments=[{"key": "boh", "path": "task_results/a.jpg", "name": "x"}])
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_other_task_types_rejected_and_unknown_task(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, _, _ = await _setup(client, db_session, maker_user, admin_user, "TR3")
|
||||
measure = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
json={"title": "M", "task_type": "measure"},
|
||||
)).json()
|
||||
resp = await _post(client, measurement_tec_user, task_id=measure["id"], outcome="ok")
|
||||
assert resp.status_code == 422
|
||||
resp = await _post(client, measurement_tec_user, task_id=999999, outcome="ok")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_run_must_be_open_and_same_version(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4")
|
||||
_, other_check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4b")
|
||||
run = await _open_run(client, measurement_tec_user, recipe, "TR4")
|
||||
|
||||
# task di un'altra versione
|
||||
resp = await _post(client, measurement_tec_user, task_id=other_check["id"],
|
||||
production_run_id=run["id"], outcome="ok")
|
||||
assert resp.status_code == 422
|
||||
# run inesistente
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=999999, outcome="ok")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# run chiuso: non registra piu'
|
||||
row = (await db_session.execute(
|
||||
select(ProductionRun).where(ProductionRun.id == run["id"])
|
||||
)).scalar_one()
|
||||
row.status = "closed"
|
||||
await db_session.commit()
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=run["id"], outcome="ok")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_list_filters_and_order(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, check, note = await _setup(client, db_session, maker_user, admin_user, "TR5")
|
||||
run = await _open_run(client, measurement_tec_user, recipe, "TR5")
|
||||
a = (await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=run["id"], outcome="ok")).json()
|
||||
b = (await _post(client, measurement_tec_user, task_id=note["id"],
|
||||
production_run_id=run["id"], outcome="done")).json()
|
||||
c = (await _post(client, measurement_tec_user, task_id=check["id"], outcome="ok")).json()
|
||||
h = auth_headers(measurement_tec_user)
|
||||
|
||||
resp = await client.get(f"/api/task-results?production_run_id={run['id']}", headers=h)
|
||||
assert [r["id"] for r in resp.json()] == [b["id"], a["id"]] # piu' recente prima
|
||||
resp = await client.get(f"/api/task-results?task_id={check['id']}", headers=h)
|
||||
assert [r["id"] for r in resp.json()] == [c["id"], a["id"]]
|
||||
resp = await client.get(
|
||||
f"/api/task-results?task_id={check['id']}&production_run_id={run['id']}", headers=h)
|
||||
assert [r["id"] for r in resp.json()] == [a["id"]]
|
||||
assert (await client.get("/api/task-results")).status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/task-results/attachments: l'operatore carica gli allegati (#55)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# firma PNG valida (magic bytes) piu' un po' di zeri
|
||||
PNG = bytes([0x89]) + b"PNG" + bytes([13, 10, 26, 10]) + bytes(32)
|
||||
|
||||
|
||||
async def test_attachment_upload_ok_for_measurement_tec(
|
||||
client: AsyncClient, measurement_tec_user, tmp_path,
|
||||
):
|
||||
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=auth_headers(measurement_tec_user),
|
||||
files={"file": ("foto bella (1).png", PNG, "image/png")},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["name"] == "fotobella1.png"
|
||||
assert body["file_path"].startswith("task_results/")
|
||||
assert body["file_path"].endswith(".png")
|
||||
assert (tmp_path / body["file_path"]).read_bytes() == PNG
|
||||
|
||||
|
||||
async def test_attachment_upload_rejects_wrong_type_size_and_role(
|
||||
client: AsyncClient, measurement_tec_user, maker_user, tmp_path,
|
||||
):
|
||||
h = auth_headers(measurement_tec_user)
|
||||
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 1
|
||||
# tipo non ammesso (HTML dichiarato come png): come upload_file, 400
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=h,
|
||||
files={"file": ("x.png", b"<html></html>", "image/png")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# oltre il limite
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=h,
|
||||
files={"file": ("x.png", PNG + bytes(1024 * 1024 + 1), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# un Maker senza ruolo di reparto non carica
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=auth_headers(maker_user),
|
||||
files={"file": ("x.png", PNG, "image/png")},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert not any(tmp_path.rglob("*.png"))
|
||||
|
||||
|
||||
async def test_task_results_dir_is_undeletable(client: AsyncClient, admin_user):
|
||||
resp = await client.delete(
|
||||
"/api/files/task_results/2026/10/abc.png", headers=auth_headers(admin_user),
|
||||
)
|
||||
assert resp.status_code in (403, 404)
|
||||
assert resp.status_code == 403
|
||||
@@ -7,6 +7,14 @@ from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _measure(client, user, subtask_id, version_id, value=10.0):
|
||||
return await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(user),
|
||||
json={"subtask_id": subtask_id, "version_id": version_id, "value": value},
|
||||
)
|
||||
|
||||
|
||||
class TestListTasks:
|
||||
"""GET /api/recipes/{recipe_id}/tasks tests."""
|
||||
|
||||
@@ -65,6 +73,45 @@ class TestCreateTask:
|
||||
assert len(data["subtasks"]) == 1
|
||||
assert data["subtasks"][0]["description"] == "Surface roughness"
|
||||
|
||||
async def test_create_task_on_version_with_measurements_has_recipe_id(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
"""Copy-on-write branch (gitea#4): TaskResponse.recipe_id must not crash."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-CT")
|
||||
task = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()[0]
|
||||
subtask_id = task["subtasks"][0]["id"]
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask_id, task["version_id"]
|
||||
)).status_code == 200
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={
|
||||
"title": "New Task After Measurement",
|
||||
"subtasks": [
|
||||
{
|
||||
"marker_number": 1,
|
||||
"description": "Surface roughness",
|
||||
"nominal": 5.0,
|
||||
"utl": 5.5,
|
||||
"ltl": 4.5,
|
||||
"unit": "um",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
data = resp.json()
|
||||
assert data["recipe_id"] == recipe.id
|
||||
assert data["version_id"] != task["version_id"]
|
||||
|
||||
|
||||
class TestUpdateTask:
|
||||
"""PUT /api/tasks/{task_id} tests."""
|
||||
@@ -171,7 +218,9 @@ class TestUpdateSubtask:
|
||||
resp = await client.put(
|
||||
f"/api/subtasks/{subtask_id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"description": "Updated description", "nominal": 12.0},
|
||||
# nominal stays inside the fixture's own lwl(9.7)/uwl(10.3): #26
|
||||
# rejects a nominal like 12.0 that would land past utl(10.5).
|
||||
json={"description": "Updated description", "nominal": 10.2},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["description"] == "Updated description"
|
||||
@@ -221,3 +270,266 @@ class TestReorderTasks:
|
||||
assert resp.status_code == 200
|
||||
reordered = resp.json()
|
||||
assert [t["id"] for t in reordered] == reversed_ids
|
||||
|
||||
|
||||
class TestCopyOnWriteOnEdit:
|
||||
"""A version with measurements is never edited in place (gitea#4)."""
|
||||
|
||||
async def test_update_task_with_measurements_creates_new_version(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-UT")
|
||||
task = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()[0]
|
||||
task_id, old_version_id = task["id"], task["version_id"]
|
||||
subtask_id = task["subtasks"][0]["id"]
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask_id, old_version_id
|
||||
)).status_code == 200
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{task_id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"title": "Titolo nuovo"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["version_id"] != old_version_id
|
||||
assert resp.json()["title"] == "Titolo nuovo"
|
||||
|
||||
# The measured version keeps its original title and its versions count grew.
|
||||
old = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
|
||||
)).json()
|
||||
assert old["tasks"][0]["title"] == "Test Task"
|
||||
versions = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/versions", headers=auth_headers(maker_user),
|
||||
)).json()
|
||||
assert len(versions) == 2
|
||||
|
||||
# The measurement is still findable under the old version.
|
||||
listed = (await client.get(
|
||||
"/api/measurements/", headers=auth_headers(measurement_tec_user),
|
||||
params={"version_id": old_version_id},
|
||||
)).json()
|
||||
assert listed["total"] == 1
|
||||
|
||||
async def test_delete_task_with_measurements_creates_new_version(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-DT")
|
||||
task = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()[0]
|
||||
task_id, old_version_id = task["id"], task["version_id"]
|
||||
subtask_id = task["subtasks"][0]["id"]
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask_id, old_version_id
|
||||
)).status_code == 200
|
||||
|
||||
resp = await client.delete(
|
||||
f"/api/tasks/{task_id}", headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# The measured version still has its task untouched.
|
||||
old = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
|
||||
)).json()
|
||||
assert len(old["tasks"]) == 1
|
||||
|
||||
# The new current version has the task removed. expunge_all forces a
|
||||
# fresh read - the shared test session otherwise still has the old
|
||||
# RecipeVersion.tasks collection cached from create_test_recipe.
|
||||
db_session.expunge_all()
|
||||
current = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()
|
||||
assert current == []
|
||||
|
||||
async def test_update_subtask_with_measurements_creates_new_version(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-US")
|
||||
task = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()[0]
|
||||
old_version_id = task["version_id"]
|
||||
subtask = task["subtasks"][0]
|
||||
subtask_id = subtask["id"]
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask_id, old_version_id
|
||||
)).status_code == 200
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/subtasks/{subtask_id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"description": "Quota rivista"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["description"] == "Quota rivista"
|
||||
assert resp.json()["id"] != subtask_id, "la modifica va sulla copia, non sull'originale"
|
||||
|
||||
# The measured version's own quote is untouched.
|
||||
old = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
|
||||
)).json()
|
||||
assert old["tasks"][0]["subtasks"][0]["description"] == subtask["description"]
|
||||
|
||||
async def test_delete_subtask_with_measurements_creates_new_version(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-DS")
|
||||
task = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()[0]
|
||||
old_version_id = task["version_id"]
|
||||
subtask_id = task["subtasks"][0]["id"]
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask_id, old_version_id
|
||||
)).status_code == 200
|
||||
|
||||
resp = await client.delete(
|
||||
f"/api/subtasks/{subtask_id}", headers=auth_headers(maker_user),
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# The measured version keeps its quote.
|
||||
old = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
|
||||
)).json()
|
||||
assert len(old["tasks"][0]["subtasks"]) == 1
|
||||
|
||||
# The new current version has no quotes left on the task. expunge_all
|
||||
# forces a fresh read past the shared test session's cached collection.
|
||||
db_session.expunge_all()
|
||||
current = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()
|
||||
assert current[0]["subtasks"] == []
|
||||
|
||||
async def test_create_subtask_with_measurements_creates_new_version(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
maker_user: User,
|
||||
measurement_tec_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="COW-CS")
|
||||
task = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()[0]
|
||||
task_id, old_version_id = task["id"], task["version_id"]
|
||||
subtask_id = task["subtasks"][0]["id"]
|
||||
|
||||
assert (await _measure(
|
||||
client, measurement_tec_user, subtask_id, old_version_id
|
||||
)).status_code == 200
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/tasks/{task_id}/subtasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"marker_number": 9, "description": "Nuova quota", "utl": 5.0},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
# The measured version's task is unchanged (still one quote).
|
||||
old = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/versions/1", headers=auth_headers(maker_user),
|
||||
)).json()
|
||||
assert len(old["tasks"][0]["subtasks"]) == 1
|
||||
|
||||
# The new current version's task has both quotes. expunge_all forces a
|
||||
# fresh read past the shared test session's cached collection.
|
||||
db_session.expunge_all()
|
||||
current = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()
|
||||
assert len(current[0]["subtasks"]) == 2
|
||||
|
||||
|
||||
class TestToleranceOrder:
|
||||
"""ltl <= lwl <= nominal <= uwl <= utl, or 422 (gitea#26)."""
|
||||
|
||||
async def test_create_subtask_utl_below_ltl_is_422(
|
||||
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-A")
|
||||
task_id = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()[0]["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/tasks/{task_id}/subtasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"marker_number": 5, "description": "Quota invertita", "ltl": 8, "utl": 6},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_create_subtask_uwl_above_utl_is_422(
|
||||
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-B")
|
||||
task_id = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()[0]["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/tasks/{task_id}/subtasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"marker_number": 5, "description": "Warning oltre tolleranza", "uwl": 11, "utl": 10},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_create_subtask_one_sided_tolerance_is_ok(
|
||||
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-C")
|
||||
task_id = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()[0]["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/tasks/{task_id}/subtasks",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"marker_number": 5, "description": "Solo utl", "utl": 10.0},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
async def test_update_subtask_that_would_invert_the_order_is_422(
|
||||
self, client: AsyncClient, maker_user: User, db_session: AsyncSession,
|
||||
):
|
||||
"""Partial update: the new value is checked against what stays (#26)."""
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TOL-D")
|
||||
subtask_id = (await client.get(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
)).json()[0]["subtasks"][0]["id"]
|
||||
|
||||
# Fixture quote: ltl=9.5, lwl=9.7, nominal=10.0, uwl=10.3, utl=10.5.
|
||||
# Raising ltl past the existing utl inverts the sequence.
|
||||
resp = await client.put(
|
||||
f"/api/subtasks/{subtask_id}",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"ltl": 11.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""UTC in the DB, explicit +00:00 on the wire, local time only on presentation (#42)."""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import report_service
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.utils.tempo import as_utc_naive, to_local, utcnow
|
||||
|
||||
|
||||
def test_utcnow_naive_and_close_to_utc():
|
||||
now = utcnow()
|
||||
assert now.tzinfo is None
|
||||
delta = datetime.now(timezone.utc).replace(tzinfo=None) - now
|
||||
assert abs(delta.total_seconds()) < 5
|
||||
|
||||
|
||||
def test_as_utc_naive():
|
||||
parsed = datetime.fromisoformat("2026-10-06T10:00:00+02:00")
|
||||
assert as_utc_naive(parsed) == datetime(2026, 10, 6, 8, 0)
|
||||
assert as_utc_naive(parsed).tzinfo is None
|
||||
naive = datetime(2026, 10, 6, 8, 0)
|
||||
assert as_utc_naive(naive) is naive
|
||||
assert as_utc_naive(None) is None
|
||||
|
||||
|
||||
def test_to_local_is_rome():
|
||||
assert to_local(datetime(2026, 10, 6, 8, 0)).strftime("%H:%M %z") == "10:00 +0200"
|
||||
assert to_local(datetime(2026, 1, 6, 8, 0)).strftime("%H:%M %z") == "09:00 +0100"
|
||||
|
||||
|
||||
async def _make_measurement(client, user, recipe_id) -> None:
|
||||
tasks = (await client.get(f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user))).json()
|
||||
await client.post(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(user),
|
||||
json={
|
||||
"subtask_id": tasks[0]["subtasks"][0]["id"],
|
||||
"version_id": tasks[0]["version_id"],
|
||||
"value": 10.0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def test_api_datetimes_carry_utc_offset_and_filter_equivalence(
|
||||
client: AsyncClient,
|
||||
measurement_tec_user: User,
|
||||
metrologist_user: User,
|
||||
db_session: AsyncSession,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
await _make_measurement(client, measurement_tec_user, recipe.id)
|
||||
|
||||
resp = await client.get("/api/measurements/", headers=auth_headers(metrologist_user))
|
||||
item = resp.json()["items"][0]
|
||||
assert item["measured_at"].endswith("+00:00")
|
||||
|
||||
# Same instant written with +02:00 and as UTC: identical results.
|
||||
future_utc = "2099-01-01T08:00:00+00:00"
|
||||
future_rome = "2099-01-01T10:00:00+02:00"
|
||||
past_utc = "2000-01-01T08:00:00+00:00"
|
||||
past_rome = "2000-01-01T10:00:00+02:00"
|
||||
for frm, to in ((past_utc, future_utc), (past_rome, future_rome)):
|
||||
r = await client.get(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(metrologist_user),
|
||||
params={"date_from": frm, "date_to": to},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["total"] == 1
|
||||
|
||||
# Boundary: measurement is "now" in UTC; a date_from 1h ahead in UTC,
|
||||
# written as +02:00 (now+3h local wall clock), must exclude it.
|
||||
ahead = (utcnow().replace(microsecond=0)).replace(tzinfo=timezone.utc)
|
||||
from datetime import timedelta
|
||||
ahead_utc = (ahead + timedelta(hours=1)).isoformat()
|
||||
ahead_rome = (ahead + timedelta(hours=1)).astimezone(
|
||||
timezone(timedelta(hours=2))
|
||||
).isoformat()
|
||||
totals = []
|
||||
for frm in (ahead_utc, ahead_rome):
|
||||
r = await client.get(
|
||||
"/api/measurements/",
|
||||
headers=auth_headers(metrologist_user),
|
||||
params={"date_from": frm},
|
||||
)
|
||||
totals.append(r.json()["total"])
|
||||
assert totals == [0, 0]
|
||||
|
||||
|
||||
async def test_report_generated_at_is_local_time(
|
||||
db_session: AsyncSession, metrologist_user: User
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, metrologist_user.id)
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def fake_render(html: str) -> bytes:
|
||||
captured["html"] = html
|
||||
return b"%PDF"
|
||||
|
||||
with patch.object(report_service, "utcnow", return_value=datetime(2026, 10, 6, 8, 0)), \
|
||||
patch.object(report_service, "_render_pdf", side_effect=fake_render):
|
||||
await report_service.generate_measurement_report(db_session, recipe.id)
|
||||
|
||||
assert "Generated 2026-10-06 10:00" in captured["html"]
|
||||
|
||||
|
||||
def test_report_filter_day_is_local():
|
||||
"""UTC naive 22:00 of Oct 5 is already Oct 6 in Europe/Rome (#42)."""
|
||||
assert report_service._giorno_locale(datetime(2026, 10, 5, 22, 0, 0)) == "2026-10-06"
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for PUT /api/users/{id}/password (admin only) - issue #25(c).
|
||||
|
||||
Kept in its own file rather than test_users.py: that file covers the rest of
|
||||
the users router, worked on in a different lot in the same session.
|
||||
"""
|
||||
from httpx import AsyncClient
|
||||
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.tests.conftest import auth_headers
|
||||
|
||||
|
||||
class TestChangeUserPassword:
|
||||
async def test_admin_changes_password_invalidates_api_key(
|
||||
self, client: AsyncClient, admin_user: User, maker_user: User
|
||||
):
|
||||
"""Changing a user's password invalidates their existing api_key,
|
||||
|
||||
the same as setup.py's change-user-password (setup.py:634).
|
||||
"""
|
||||
old_key = maker_user.plain_key
|
||||
old_username = maker_user.username
|
||||
assert old_key is not None
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/users/{maker_user.id}/password",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"password": "NewSecurePass1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "changed" in resp.json()["message"].lower()
|
||||
|
||||
# The old key no longer authenticates. Read it before the request
|
||||
# above: the client and app share one session (see conftest), so
|
||||
# `maker_user.plain_key` in this process reflects the mutation too.
|
||||
resp2 = await client.get("/api/auth/me", headers={"X-API-Key": old_key})
|
||||
assert resp2.status_code == 401
|
||||
|
||||
# The new password works and issues a fresh key.
|
||||
resp3 = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": old_username, "password": "NewSecurePass1"},
|
||||
)
|
||||
assert resp3.status_code == 200
|
||||
assert resp3.json()["api_key"] != old_key
|
||||
|
||||
async def test_non_admin_forbidden(
|
||||
self, client: AsyncClient, maker_user: User
|
||||
):
|
||||
resp = await client.put(
|
||||
f"/api/users/{maker_user.id}/password",
|
||||
headers=auth_headers(maker_user),
|
||||
json={"password": "WhateverPass1"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_user_not_found(
|
||||
self, client: AsyncClient, admin_user: User
|
||||
):
|
||||
resp = await client.put(
|
||||
"/api/users/999999/password",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"password": "WhateverPass1"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_password_too_short_rejected(
|
||||
self, client: AsyncClient, admin_user: User, maker_user: User
|
||||
):
|
||||
resp = await client.put(
|
||||
f"/api/users/{maker_user.id}/password",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"password": "short"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
@@ -107,6 +107,61 @@ class TestUpdateUser:
|
||||
assert resp.json()["display_name"] == "Updated Maker"
|
||||
|
||||
|
||||
class TestUpdateUserValidation:
|
||||
"""gitea#36 point 4: roles/is_admin cannot be null, admin self/last-admin guard."""
|
||||
|
||||
async def test_roles_null_is_422(
|
||||
self, client: AsyncClient, admin_user: User, maker_user: User,
|
||||
):
|
||||
"""roles is Optional in the schema but NOT NULL in the DB: an explicit
|
||||
null used to reach setattr() and blow up as an unhandled 500."""
|
||||
resp = await client.put(
|
||||
f"/api/users/{maker_user.id}",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"roles": None},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_is_admin_null_is_422(
|
||||
self, client: AsyncClient, admin_user: User, maker_user: User,
|
||||
):
|
||||
resp = await client.put(
|
||||
f"/api/users/{maker_user.id}",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"is_admin": None},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_admin_cannot_remove_own_admin_flag(
|
||||
self, client: AsyncClient, admin_user: User,
|
||||
):
|
||||
"""admin_user is the only admin: this is also the only reachable
|
||||
"last admin" case, since only an admin can call this endpoint at
|
||||
all — removing someone else's flag always leaves the caller."""
|
||||
resp = await client.put(
|
||||
f"/api/users/{admin_user.id}",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"is_admin": False},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_admin_can_demote_another_admin_when_not_last(
|
||||
self, client: AsyncClient, admin_user: User, db_session: AsyncSession,
|
||||
):
|
||||
"""Demoting someone else is fine as long as an admin remains (the
|
||||
caller): the guard must not be broader than the two stated cases."""
|
||||
second_admin = await _create_user(
|
||||
db_session, username="second_admin", roles=[], is_admin=True,
|
||||
)
|
||||
resp = await client.put(
|
||||
f"/api/users/{second_admin.id}",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"is_admin": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_admin"] is False
|
||||
|
||||
|
||||
class TestDeleteUser:
|
||||
"""DELETE /api/users/{user_id} tests."""
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ async def two_vision_quotes(db_session, recipe_id: int):
|
||||
def worker_says(monkeypatch):
|
||||
"""The worker does not run in tests: its response is faked here."""
|
||||
def _install(outputs: dict, failures: list | None = None):
|
||||
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
return {
|
||||
"outputs": outputs,
|
||||
"failures": failures or [],
|
||||
@@ -159,6 +159,50 @@ async def test_a_missing_output_names_itself(
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_null_output_counts_as_not_produced(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""A worker answering with the key present but null (not omitted) must not
|
||||
reach float(None) as a bare TypeError - it is "not produced", same as a
|
||||
missing key (#16)."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
worker_says({"t1.diameter": None, "t1.radius": GOOD})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "t1.diameter" in response.json()["detail"]
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_non_finite_output_counts_as_not_produced(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""NaN/inf must not become a saved measurement (#16)."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, first, second = await two_vision_quotes(db_session, recipe.id)
|
||||
worker_says({"t1.diameter": float("nan"), "t1.radius": GOOD})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "t1.diameter" in response.json()["detail"]
|
||||
assert (await db_session.execute(select(Measurement))).scalars().all() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_task_without_a_graph_is_refused(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
@@ -272,7 +316,7 @@ async def test_a_pending_fail_blocks_before_the_worker_is_called(
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def worker_must_not_be_called(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
raise AssertionError("the worker must not run while the operator is blocked")
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service._call_worker", worker_must_not_be_called,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""#38.7: /api/vision/execute must not run a camera graph against a task that
|
||||
is not one, a version that is no longer current, an inactive recipe, or a
|
||||
production run that belongs to someone else's job or is already closed - and
|
||||
lot_number/serial_number/production_run_id must not reach MySQL unbounded.
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.api.production import ProductionRunCreate
|
||||
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 production_service
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
|
||||
def an_image() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
async def recipe_parts(db_session, recipe_id: int):
|
||||
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()
|
||||
subtask = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
return version, task, subtask
|
||||
|
||||
|
||||
async def a_camera_task(db_session, recipe_id: int):
|
||||
version, task, subtask = await recipe_parts(db_session, recipe_id)
|
||||
task.task_type = "camera_measure"
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
subtask.vision_output = "t1.diameter"
|
||||
await db_session.commit()
|
||||
await db_session.refresh(task)
|
||||
await db_session.refresh(subtask)
|
||||
return version, task, subtask
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_says(monkeypatch):
|
||||
def _install(outputs: dict):
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
return {"outputs": outputs, "failures": [],
|
||||
"engine_version": "e" * 40, "duration_ms": 3}
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service._call_worker", fake_call,
|
||||
)
|
||||
return _install
|
||||
|
||||
|
||||
async def _station(db_session, user_id: int, code: str = "ST-VIS") -> Station:
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_a_task_that_is_not_a_camera_measurement(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||
# left at its fixture default task_type ("measure"), no vision_json
|
||||
worker_says({"t1.diameter": 10.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "camera" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_a_non_current_version(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, subtask = await a_camera_task(db_session, recipe.id)
|
||||
version.is_current = False
|
||||
await db_session.commit()
|
||||
worker_says({"t1.diameter": 10.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_an_inactive_recipe(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, subtask = await a_camera_task(db_session, recipe.id)
|
||||
recipe.active = False
|
||||
await db_session.commit()
|
||||
worker_says({"t1.diameter": 10.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_a_production_run_of_another_recipe(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-1")
|
||||
version, task, subtask = await a_camera_task(db_session, recipe.id)
|
||||
other_recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-2")
|
||||
station = await _station(db_session, measurement_tec_user.id)
|
||||
run = await production_service.open_run(
|
||||
db_session,
|
||||
ProductionRunCreate(station_code=station.code, recipe_id=other_recipe.id),
|
||||
measurement_tec_user,
|
||||
)
|
||||
await db_session.commit()
|
||||
worker_says({"t1.diameter": 10.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id), "production_run_id": str(run.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "recipe" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_a_closed_production_run(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-3")
|
||||
version, task, subtask = await a_camera_task(db_session, recipe.id)
|
||||
station = await _station(db_session, measurement_tec_user.id, code="ST-VIS-2")
|
||||
run = await production_service.open_run(
|
||||
db_session,
|
||||
ProductionRunCreate(station_code=station.code, recipe_id=recipe.id),
|
||||
measurement_tec_user,
|
||||
)
|
||||
await db_session.commit()
|
||||
await production_service.close_run(
|
||||
db_session, run, measurement_tec_user, measurement_tec_user,
|
||||
)
|
||||
await db_session.commit()
|
||||
worker_says({"t1.diameter": 10.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id), "production_run_id": str(run.id)},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_a_lot_number_over_the_column_limit(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
"""max_length=100: a longer value must be refused before it can 500 a
|
||||
strict MySQL VARCHAR(100) column."""
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, subtask = await a_camera_task(db_session, recipe.id)
|
||||
worker_says({"t1.diameter": 10.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id), "lot_number": "L" * 101},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_a_non_positive_production_run_id(
|
||||
client, db_session, measurement_tec_user, worker_says,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||
version, task, subtask = await a_camera_task(db_session, recipe.id)
|
||||
worker_says({"t1.diameter": 10.0})
|
||||
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id), "production_run_id": "0"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user