Compare commits

...

73 Commits

Author SHA1 Message Date
Adriano 8d7fa9a12a Statistica: pannello esiti, note, campi e allegati dei task per sessione, proxy e traduzioni (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:08:18 +00:00
Adriano c911449b7c Statistica: endpoint esiti, note, campi e allegati dei task di una sessione (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:07:06 +00:00
Adriano ac8604eff5 Merge lavoro/daitarn-45: worker di visione configurabile per stazione (Fixes #45)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:03:57 +00:00
Adriano 631ba4ee05 Stazioni: placeholder senza URL letterale (test_offline), test form indipendente dalla lingua (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:49:46 +00:00
Adriano 40eed5c2af Admin stazioni: campo Worker visione (URL), traduzioni e test (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:39:22 +00:00
Adriano 275bbd1a5f Test worker di visione per stazione, API stazioni, migrazione 019 (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:38:03 +00:00
Adriano 95d643d6d8 Worker di visione per stazione: modello, API, migrazione 019, execute_task (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:37:08 +00:00
Adriano 9306fbf653 Merge lavoro/daitarn-tmf69: vendor visionsuite adf7a26 (Fixes #69)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 16:44:06 +02:00
Adriano 1a5e98f072 vendor visionsuite adf7a26: ricette robuste ai PermissionError di Windows (#69)
Da dbd1a55 a adf7a26 (visionsuite#279): vs-task server/recipes.py ritenta lettura e scrittura su PermissionError. uv.lock invariato (uv lock --check ok). Suite visione 3.13: 34 passati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 16:44:03 +02:00
Adriano fdfcf05951 Merge lavoro/daitarn-tmf68: vendor visionsuite riallineato a main dbd1a55 (Fixes #68)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 14:07:03 +02:00
Adriano 26884ebf4f vendor visionsuite dbd1a55: riallineato a main (#68)
Da 1e45b33 a dbd1a55 (87 commit): vs-dxf, vs-pm2d, vs-tools, vs-task aggiornati; uv.lock invariato (uv lock --check ok).
Suite visione 3.13: prima 29 passati, dopo 34 passati (con test_vision_boundary).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 14:06:50 +02:00
Adriano 8b3d3a8424 vendor visionsuite 1e45b33: Ricette a griglia con azioni in riga (visionsuite#262) (#67)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNVrf2wZAmMAkgmwEuBB36
2026-10-07 22:08:56 +02:00
Adriano 153fafd1b4 vendor/visionsuite a main 8d5ac89: quote, Misure, tolleranza, tab, SORGENTE (#67)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNVrf2wZAmMAkgmwEuBB36
2026-10-07 21:35:31 +02:00
Adriano cf8fab4c08 vendor/visionsuite a main 9e25714 (#67)
GUI vs-gui riprogettata, choices nel catalogo, enum feature_layout in inglese con alias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNVrf2wZAmMAkgmwEuBB36
2026-10-07 18:41:16 +02:00
Adriano b7cb061fee Suite: test PIN, cornice e worker PDF allineati all'integrazione (#65)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:46:27 +02:00
Adriano 6a8ad4dae4 Allegati delle note verso /api/task-results/attachments (#55)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNVrf2wZAmMAkgmwEuBB36
2026-10-07 13:12:47 +02:00
Adriano d750cf9e0e Merge lavoro/daitarn-trafilo-p5 2026-10-07 13:11:58 +02:00
Adriano 2564498915 Test copy-on-write dei task: rilettura della collezione in sessione condivisa (#53)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:11:30 +02:00
Adriano 21f8bae5ac Task results: upload allegati per MeasurementTec, percorsi in task_results/, test e migrazione 018 (#55 #53 #54)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:50:42 +02:00
Adriano c1f8a5e2ff Merge lavoro/daitarn-trafilo-i18n 2026-10-07 12:49:12 +02:00
Adriano dee27ac8e1 Traduzioni it/en stringhe Tràfilo (#47–#64)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:48:53 +02:00
Adriano 736b853e22 Fermo linea e Fine produzione anche sui task check del ciclo (#53)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VNVrf2wZAmMAkgmwEuBB36
2026-10-07 12:47:00 +02:00
Adriano 627ae15591 Merge lavoro/daitarn-trafilo-esegui-esiti 2026-10-07 12:43:57 +02:00
Adriano c6d4260ede Esecuzione: esiti OK/NOK, campi dei task note, task di chiusura prima del capoturno (#53 #54 #55)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:43:04 +02:00
Adriano df1727be73 Task: in_loop, at_close, fields, tipo check, task_results, close_task_ids, rate limit login-pin (#53 #54 #55 #59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:42:13 +02:00
Adriano 459d1c71ea Merge lavoro/daitarn-trafilo-esegui-pin 2026-10-07 12:39:59 +02:00
Adriano 05e3faa9d4 Merge lavoro/daitarn-trafilo-utenti-pin 2026-10-07 12:39:58 +02:00
Adriano dc94b2e778 Capoturno: autorizzazione con PIN (nome + tastierino) o password nella modale (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:39:07 +02:00
Adriano 3c55550d93 Utenti: PIN impostabile/togliibile dall'admin, link Monitor nel menu (#51 #59 #57)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:38:58 +02:00
Adriano e07c4d4eee Merge lavoro/daitarn-trafilo-editor-task 2026-10-07 12:38:57 +02:00
Adriano 0b45f1c35a Merge lavoro/daitarn-trafilo-editor-quote 2026-10-07 12:38:56 +02:00
Adriano 743f246cbf Merge lavoro/daitarn-trafilo-pin 2026-10-07 12:38:56 +02:00
Adriano f541f4a810 Editor task: tipo check, ciclo di misura, nota a fine produzione con campi (#53 #54 #55)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:38:05 +02:00
Adriano dffb11f9f2 task editor: liste Tipo/Unita e tolleranze generali dalle Impostazioni (#47, #48)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:37:14 +02:00
Adriano c8dd5eecfd Merge lavoro/daitarn-trafilo-ritaglio 2026-10-07 12:33:02 +02:00
Adriano f7d6d008c7 Merge lavoro/daitarn-trafilo-duplica 2026-10-07 12:33:02 +02:00
Adriano 837f3d1069 Merge lavoro/daitarn-trafilo-statsessione 2026-10-07 12:33:02 +02:00
Adriano f5a2affa0f Merge lavoro/daitarn-trafilo-monitor 2026-10-07 12:33:02 +02:00
Adriano 7ef28b7a9f Merge lavoro/daitarn-trafilo-operatore 2026-10-07 12:33:02 +02:00
Adriano 06b87a15b3 Merge lavoro/daitarn-trafilo-login 2026-10-07 12:33:02 +02:00
Adriano 3b3ecceb2a Merge lavoro/daitarn-trafilo-settings 2026-10-07 12:33:02 +02:00
Adriano 3899d5ecd9 Merge lavoro/daitarn-46-motore: percorsi con lettera di unità (#46) 2026-10-07 12:32:47 +02:00
Adriano f986c172c2 Worker: recipe_dir con lettera di unita rifiutato su ogni OS (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:32:34 +02:00
Adriano e44ac3129f Statistiche sessioni: conteggio misure solo sui run della ricetta (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:31:53 +02:00
Adriano 4e52095423 Statistiche per sessione di produzione: filtro production_run_id, elenco sessioni, select Sessione (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:28:35 +02:00
Adriano 23bfc93f1c Impostazioni: durata loop, tolleranze, anagrafiche; tab Utenti con reset password (#47 #48 #49 #50 #51)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:28:31 +02:00
Adriano 07e23b957d PIN a 4 cifre: migrazione 017, login-pin, pin-users, PUT pin, capoturno via PIN (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:27:31 +02:00
Adriano c1c7ae8d0e Merge lavoro/daitarn-46-operatore (#46) 2026-10-07 12:27:16 +02:00
Adriano cf49695140 Merge lavoro/daitarn-46-maker (#46) 2026-10-07 12:27:16 +02:00
Adriano c9e01a223b Merge lavoro/daitarn-46-backend (#46) 2026-10-07 12:27:15 +02:00
Adriano 3690c35c47 Merge lavoro/daitarn-46-motore (#46) 2026-10-07 12:27:15 +02:00
Adriano fc1902a1bb Ricetta: il digest della cartella copre anche i file di meta (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:27:04 +02:00
Adriano 6648882ec1 Maker: duplica ricetta e durata loop predefinita (#52, #49)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:26:49 +02:00
Adriano b5aa19170e Maker: ritaglio di immagine e PDF prima dell'upload (#56)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:26:38 +02:00
Adriano 2d03577165 loadProductionRun mostra il detail del server; START ACTIVITY in inglese (#61 #62)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:26:33 +02:00
Adriano 27dc5e77af Schermate operatore: un solo Completato grande, Fermo linea solo con run aperto, AVVIA ATTIVITA, pannello lotto/seriale dopo la scelta (#60 #61 #62 #63 #64)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:25:56 +02:00
Adriano 8e3316957c Monitor stazioni: pagina /monitor e proxy JSON (#57)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:25:51 +02:00
Adriano 3c1d5f1323 Duplica ricetta: servizio, endpoint e test backend (#52)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:25:25 +02:00
Adriano db5d686938 Monitor stazioni: GET /api/production-runs/open (#57)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:25:16 +02:00
Adriano c764cf73c6 Login Operatore con nome + PIN: elenco nomi, tastierino, route login-pin (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:23:51 +02:00
Adriano 86290274e5 Test ricetta v2, execute dxf_compare, migrazione 016 (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:15:02 +02:00
Adriano ee0f65ed39 Endpoint ricetta VisionSuite v2, execute con overlay e immagine salvata (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:12:50 +02:00
Adriano 18d0db8a0c Migrazione 016 vision_recipe_dir, copy-on-write, dxf_compare tra i task di misura (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:08:29 +02:00
Adriano 3856ee335a operatore: acquisizione immagine, proxy execute e overlay per camera_measure/dxf_compare (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:55:42 +02:00
Adriano e640c4a36e Maker: ricetta visione e uscita per quota, tipi camera_measure/dxf_compare (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:55:31 +02:00
Adriano accf416cc9 Motore v2: run_graph con recipe_dir/calibrazione/overlay, /validate, test (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:54:27 +02:00
Adriano 84a4b5f34d Regola submodule VisionSuite su origin/main (bc0df16), uv.lock (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:51:06 +02:00
Adriano 9238f8926b visionsuite bc0df16, vs-tools e vs-dxf nelle dipendenze (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:50:35 +02:00
Adriano a829bf3eb0 frontend: date UTC convertite in ora italiana alla presentazione (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:59:18 +02:00
Adriano 7ac291b8ac Backend: date dei filtri dei report e utenti setup in ora italiana/UTC esplicito (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:59:07 +02:00
Adriano 486e1288bf Backend: UTC nel DB, +00:00 nelle API, ora italiana solo in presentazione (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:41:22 +02:00
Adriano 0d004c43ff Login: cancella le key scadute dell'utente (#41)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00
Adriano 35f46926a9 API key come hash SHA-256, una per sessione, TTL rinnovabile (#41)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00
132 changed files with 13105 additions and 2749 deletions
+2
View File
@@ -92,6 +92,8 @@ SESSION_COOKIE_SECURE=true
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.
+2
View File
@@ -289,6 +289,8 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
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
+1
View File
@@ -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
+4
View File
@@ -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
@@ -70,6 +71,9 @@ 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:
+4
View File
@@ -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"
+22 -4
View File
@@ -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
+3 -1
View File
@@ -35,6 +35,8 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
"""
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
@@ -97,7 +99,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
return True
if method != "POST":
return False
if path == cls.LOGIN_PATH:
if path in (cls.LOGIN_PATH, cls.LOGIN_PIN_PATH):
return True
return any(p.match(path) for p in cls._SUPERVISOR_CREDENTIAL_PATTERNS)
+45 -7
View File
@@ -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,17 +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. The API key is shared across stations logged in as the same
user, so by default this does not invalidate it - only this station's
session (its Flask session, on the client side) ends. Pass
?all_sessions=true to invalidate the key everywhere."""
await logout_user(db, user, all_sessions=all_sessions)
"""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)
+2 -1
View File
@@ -61,7 +61,8 @@ def detect_file_type(content: bytes) -> tuple[str, str] | None:
# Top-level folders nothing may DELETE through this router: statistics/reports are
# audit records, the logo is only ever replaced via POST /api/settings/logo.
_UNDELETABLE_PREFIXES = ("statistics/", "reports/", "logos/")
# 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
+9 -6
View File
@@ -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
@@ -145,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,
@@ -232,8 +235,8 @@ 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),
@@ -290,8 +293,8 @@ 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),
@@ -372,7 +375,7 @@ async def export_measurements_csv(
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,
])
@@ -382,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"
},
)
+29 -1
View File
@@ -20,12 +20,14 @@ 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",
}
@@ -52,6 +54,8 @@ 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),
)
@@ -81,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,
@@ -141,6 +166,7 @@ async def pause_production_run(
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)
@@ -157,6 +183,7 @@ async def resume_production_run(
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)
@@ -173,6 +200,7 @@ async def close_production_run(
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)
+13
View File
@@ -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),
+9 -5
View File
@@ -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(
+43
View File
@@ -1,4 +1,7 @@
"""System settings router - read/update settings, upload company logo."""
import json
from decimal import Decimal, InvalidOperation
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +21,23 @@ _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.
@@ -41,6 +61,29 @@ def _validate_setting_value(key: str, value: str) -> None:
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(
+9 -12
View File
@@ -5,8 +5,8 @@ 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
@@ -25,7 +25,7 @@ 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"])
@@ -321,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(
@@ -332,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",
@@ -404,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)
@@ -558,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
@@ -606,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
],
@@ -652,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()
@@ -707,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}'"}
@@ -730,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}
+143 -9
View File
@@ -1,15 +1,18 @@
"""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.task_result import TaskResult # #58: esiti dei task della sessione
from src.backend.models.orm.user import User
from src.backend.models.api.statistics import (
CapabilityData,
@@ -37,6 +40,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 +70,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 +107,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 +120,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 +131,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 +144,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 +156,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 +169,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 +185,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 +199,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 +249,122 @@ 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
]
@router.get("/runs/{run_id}/task-results")
async def get_run_task_results(
run_id: int,
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> dict:
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
il ruolo MeasurementTec e non si puo' riusare qui.
"""
run = (await db.execute(
select(ProductionRun.id).where(ProductionRun.id == run_id)
)).scalar_one_or_none()
if run is None:
raise HTTPException(status_code=404, detail="Sessione non trovata")
# #58: una query per i risultati della run, una per i task, una per gli utenti.
results = (await db.execute(
select(TaskResult)
.where(TaskResult.production_run_id == run_id)
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
)).scalars().all()
task_ids = {r.task_id for r in results}
tasks = {}
if task_ids:
tasks = {t.id: t for t in (await db.execute(
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
)).scalars().all()}
user_ids = {r.user_id for r in results}
names = {}
if user_ids:
names = dict((await db.execute(
select(User.id, User.display_name).where(User.id.in_(user_ids))
)).all())
counts = {"ok": 0, "nok": 0, "done": 0}
out = []
for r in results:
counts[r.outcome] = counts.get(r.outcome, 0) + 1
task = tasks.get(r.task_id)
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
labels = {
f.get("key"): f.get("label") or f.get("key")
for f in (task.fields or []) if isinstance(f, dict)
} if task else {}
out.append({
"id": r.id,
"task_id": r.task_id,
"task_title": task.title if task else None,
"task_type": task.task_type if task else None,
"outcome": r.outcome,
"note": r.note,
"created_at": r.created_at.isoformat() if r.created_at else None,
"user_name": names.get(r.user_id),
"values": [
{"key": k, "label": labels.get(k, k), "value": v}
for k, v in (r.values or {}).items()
],
"attachments": [
{
"key": a.get("key"),
"label": labels.get(a.get("key"), a.get("key")),
"name": a.get("name"),
"path": a.get("path"),
}
for a in (r.attachments or []) if isinstance(a, dict)
],
})
return {"run_id": run_id, "counts": counts, "results": out}
+192
View File
@@ -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]
+34
View File
@@ -17,6 +17,9 @@ 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.models.orm.vision import VisionReferenceImage
@@ -202,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()
@@ -240,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()
@@ -341,6 +355,26 @@ 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():
+35 -5
View File
@@ -6,8 +6,14 @@ 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"])
@@ -146,11 +152,35 @@ async def change_user_password(
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.password_hash = hash_password(data.password)
user.api_key = None
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,
@@ -168,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()
@@ -183,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}"}
+115 -3
View File
@@ -5,19 +5,25 @@ 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, VisionReferenceImageResponse,
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
@@ -126,7 +132,7 @@ async def execute(
) -> 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),
@@ -135,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()
@@ -147,6 +154,111 @@ 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,
)
+6
View File
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
db_user: str = "tmflow"
db_password: str = _DEFAULT_DB_PASSWORD
# Presentation time zone (DB is UTC, #42)
display_timezone: str = "Europe/Rome"
# Server
server_host: str = "0.0.0.0"
server_port: int = 8000
@@ -38,6 +41,9 @@ class Settings(BaseSettings):
# 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
+2
View File
@@ -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,54 @@
"""API keys as SHA-256 hashes, one per session (#41)
Replaces the plaintext `users.api_key` column with an `api_keys` table: one
row per login, holding only the SHA-256 hex digest, an expiry and the last
use. Existing keys are invalidated on purpose, not migrated - the plaintext
is exactly what must stop existing, so everybody logs in once again.
Revision ID: 015_api_key_hash
Revises: 014_vision_fk_ondelete
Create Date: 2026-10-06
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '015_api_key_hash'
down_revision: Union[str, None] = '014_vision_fk_ondelete'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'api_keys',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('key_hash', sa.String(64), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
),
)
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
with op.batch_alter_table('users') as batch:
batch.drop_index('ix_users_api_key')
batch.drop_constraint('uq_users_api_key', type_='unique')
batch.drop_column('api_key')
def downgrade() -> None:
with op.batch_alter_table('users') as batch:
batch.add_column(sa.Column('api_key', sa.String(64), nullable=True))
batch.create_unique_constraint('uq_users_api_key', ['api_key'])
batch.create_index('ix_users_api_key', ['api_key'])
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
op.drop_table('api_keys')
@@ -0,0 +1,30 @@
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
Percorso relativo sotto upload_path della cartella immutabile che contiene
recipe.json e i file di meta (DXF, modelli .npz, ...).
Revision ID: 016_vision_recipe_dir
Revises: 015_api_key_hash
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '016_vision_recipe_dir'
down_revision: Union[str, None] = '015_api_key_hash'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.drop_column('vision_recipe_dir')
@@ -0,0 +1,34 @@
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
Revision ID: 017_user_pin
Revises: 016_vision_recipe_dir
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '017_user_pin'
down_revision: Union[str, None] = '016_vision_recipe_dir'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
with op.batch_alter_table('users') as batch:
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
batch.add_column(sa.Column(
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('users') as batch:
batch.drop_column('pin_locked_until')
batch.drop_column('pin_failed_count')
batch.drop_column('pin_hash')
@@ -0,0 +1,108 @@
"""task: in_loop / at_close / fields, tipo 'check', tabella task_results (#53 #54 #55)
in_loop: il task fa parte del ciclo di produzione (prima: implicito dal tipo).
at_close: il task 'note' si compila alla fine produzione.
fields: campi extra del task 'note' (JSON).
task_results: esito registrato da un task 'check' (OK/NOK) o 'note' (done).
Revision ID: 018_task_loop_results
Revises: 017_user_pin
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '018_task_loop_results'
down_revision: Union[str, None] = '017_user_pin'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
OLD_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
NEW_TYPES = OLD_TYPES + ('check',)
# stessi tipi di MEASURING_TASK_TYPES: fino a oggi erano l'unico ciclo possibile
LOOP_TYPES = ('measure', 'camera_measure', 'dxf_compare')
def upgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column(
'in_loop', sa.Boolean(), nullable=False, server_default=sa.false()))
batch.add_column(sa.Column(
'at_close', sa.Boolean(), nullable=False, server_default=sa.false()))
batch.add_column(sa.Column('fields', sa.JSON(), nullable=True))
# enum allargato come in 011: 'check' = Controllo OK/NOK
batch.alter_column(
'task_type',
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
# i task di misura erano gia' nel ciclo: lo restano
recipe_tasks = sa.table(
'recipe_tasks', sa.column('task_type', sa.String), sa.column('in_loop', sa.Boolean))
op.execute(
recipe_tasks.update()
.where(recipe_tasks.c.task_type.in_(LOOP_TYPES))
.values(in_loop=True)
)
op.create_table(
'task_results',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('task_id', sa.Integer(),
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False),
sa.Column('version_id', sa.Integer(),
sa.ForeignKey('recipe_versions.id'), nullable=False),
sa.Column('production_run_id', sa.Integer(),
sa.ForeignKey('production_runs.id', ondelete='SET NULL'), nullable=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('outcome', sa.Enum('ok', 'nok', 'done', name='task_outcome_enum'),
nullable=False),
sa.Column('note', sa.Text(), nullable=True),
sa.Column('values', sa.JSON(), nullable=True),
sa.Column('attachments', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False,
server_default=sa.func.now()),
mysql_engine='InnoDB',
mysql_charset='utf8mb4',
)
op.create_index('ix_task_results_task_id', 'task_results', ['task_id'])
op.create_index('ix_task_results_version_id', 'task_results', ['version_id'])
op.create_index(
'ix_task_results_production_run_id', 'task_results', ['production_run_id'])
def downgrade() -> None:
# il tipo 'check' non esiste prima di 018: se un task lo usa, tornare indietro
# lo lascerebbe con un valore fuori enum
bind = op.get_bind()
used = bind.execute(
sa.text("SELECT COUNT(*) FROM recipe_tasks WHERE task_type = 'check'")
).scalar()
if used:
raise RuntimeError(
f"Downgrade 018 impossibile: {used} task usano il tipo 'check'. "
"Cambiare il tipo o eliminare quei task prima di tornare indietro."
)
op.drop_index('ix_task_results_production_run_id', table_name='task_results')
op.drop_index('ix_task_results_version_id', table_name='task_results')
op.drop_index('ix_task_results_task_id', table_name='task_results')
op.drop_table('task_results')
with op.batch_alter_table('recipe_tasks') as batch:
batch.alter_column(
'task_type',
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
existing_nullable=False,
existing_server_default='note',
)
batch.drop_column('fields')
batch.drop_column('at_close')
batch.drop_column('in_loop')
@@ -0,0 +1,34 @@
"""stations.vision_worker_url e vision_results.worker_url (#45)
stations.vision_worker_url: worker di visione della stazione (NULL = centrale).
vision_results.worker_url: worker effettivamente chiamato per quel risultato.
Revision ID: 019_station_vision_worker
Revises: 018_task_loop_results
Create Date: 2026-10-11
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '019_station_vision_worker'
down_revision: Union[str, None] = '018_task_loop_results'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('stations') as batch:
batch.add_column(sa.Column('vision_worker_url', sa.String(500), nullable=True))
with op.batch_alter_table('vision_results') as batch:
batch.add_column(sa.Column('worker_url', sa.String(500), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('vision_results') as batch:
batch.drop_column('worker_url')
with op.batch_alter_table('stations') as batch:
batch.drop_column('vision_worker_url')
+22 -9
View File
@@ -1,8 +1,8 @@
"""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):
@@ -54,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
@@ -71,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):
@@ -97,7 +110,7 @@ class CycleMeasurementResponse(BaseModel):
subtask_id: int
value: float
pass_fail: str
measured_at: datetime
measured_at: UtcDateTime
class CurrentCycleResponse(BaseModel):
@@ -114,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)
+49
View File
@@ -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
+25 -10
View File
@@ -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
+9 -3
View File
@@ -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
+25 -4
View File
@@ -1,8 +1,20 @@
"""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
from pydantic import BaseModel, ConfigDict, Field, field_validator
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
if value is None:
return None
value = value.strip()
if not value:
return None
if not value.lower().startswith(("http://", "https://")):
raise ValueError("vision_worker_url must start with http:// or https://")
return value.rstrip("/")
class StationCreate(BaseModel):
@@ -11,6 +23,10 @@ class StationCreate(BaseModel):
location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None
active: bool = True
# worker di visione della stazione (#45); vuoto = centrale
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
class StationUpdate(BaseModel):
@@ -18,6 +34,10 @@ class StationUpdate(BaseModel):
location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None
active: Optional[bool] = None
# worker di visione della stazione (#45); "" -> None riporta al centrale
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
class StationResponse(BaseModel):
@@ -28,8 +48,9 @@ class StationResponse(BaseModel):
location: Optional[str]
notes: Optional[str]
active: bool
vision_worker_url: Optional[str] = None # #45
created_by: int
created_at: datetime
created_at: UtcDateTime
class StationRecipeAssignmentCreate(BaseModel):
@@ -42,7 +63,7 @@ class StationRecipeAssignmentResponse(BaseModel):
station_id: int
recipe_id: int
assigned_by: int
assigned_at: datetime
assigned_at: UtcDateTime
class RecipeSummary(BaseModel):
+5 -5
View File
@@ -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
@@ -60,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]
+82 -1
View File
@@ -1,9 +1,10 @@
"""Pydantic schemas for RecipeTask and RecipeSubtask operations."""
import re
from typing import Any, Literal, Optional
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]
@@ -34,6 +35,76 @@ def validate_tolerance_order(
)
# #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)
@@ -98,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] = []
@@ -111,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):
@@ -129,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] = []
+37
View File
@@ -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
+14
View File
@@ -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)]
+23 -3
View File
@@ -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}$")
+30 -2
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for the vision execution response."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime
from pydantic import BaseModel, ConfigDict
@@ -16,6 +16,34 @@ 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):
@@ -28,4 +56,4 @@ class VisionReferenceImageResponse(BaseModel):
note: str | None = None
station_id: int | None = None
device_code: str | None = None
acquired_at: datetime
acquired_at: UtcDateTime
+4
View File
@@ -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",
+25
View File
@@ -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
View File
@@ -25,6 +25,8 @@ class Station(Base):
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
# URL del worker di visione della stazione (#45); NULL = worker centrale
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
+28 -3
View File
@@ -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")
+46
View File
@@ -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}>"
+12 -1
View File
@@ -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 [])
+2
View File
@@ -26,6 +26,8 @@ class VisionResult(Base):
Integer, ForeignKey("stations.id"), nullable=True
)
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# URL del worker effettivamente chiamato (#45), copiato: la stazione puo' cambiarlo dopo
worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Copied, not referenced: a calibration redone six months from now must not
# rewrite what a measurement already taken means. Same for the graph.
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
+146 -37
View File
@@ -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
@@ -50,7 +54,79 @@ async def authenticate_user(
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
@@ -58,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,
@@ -75,39 +160,67 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
return supervisor
async def login_user(db: AsyncSession, user: User) -> str:
"""Generate (or reuse) the API key and update last_login for user.
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()
Reuses an existing key rather than rotating it on every login: the same
account logged in from two stations at once (a common shift-account
pattern here) must keep both sessions working. Rotating the key would
silently log the other station out.
"""
api_key = user.api_key or generate_api_key()
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, all_sessions: bool = False
db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
) -> None:
"""Log out. By default a no-op on the shared API key.
Clearing the key on every logout would kick out any other station still
using the same shift account. `all_sessions=True` is the explicit
override for a supervisor who wants every session logged out.
"""
if not all_sessions:
"""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
await db.execute(
update(User).where(User.id == user.id).values(api_key=None)
)
await db.flush()
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(
@@ -138,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)
+2 -1
View File
@@ -1,6 +1,7 @@
"""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
@@ -343,7 +344,7 @@ async def authorise_measurement(
) -> 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
+133
View File
@@ -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
@@ -14,6 +14,7 @@ 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
@@ -169,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()
@@ -191,7 +192,7 @@ 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"
# 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.
+18 -3
View File
@@ -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
@@ -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()
# ---------------------------------------------------------------------------
@@ -144,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)
)
+78
View File
@@ -115,6 +115,12 @@ 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
@@ -253,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,
+25 -7
View File
@@ -2,6 +2,7 @@
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
@@ -32,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,
@@ -42,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 = []
@@ -67,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)
@@ -275,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.
@@ -284,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
@@ -323,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")
@@ -342,7 +356,7 @@ 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),
)
@@ -361,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
@@ -395,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 "—",
@@ -414,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")
@@ -430,7 +448,7 @@ 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),
)
+1
View File
@@ -31,6 +31,7 @@ async def create_station(
location=data.location,
notes=data.notes,
active=data.active,
vision_worker_url=data.vision_worker_url, # #45
created_by=creator.id,
)
db.add(station)
+235 -12
View File
@@ -5,6 +5,7 @@ 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
@@ -18,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.services import measurement_service
@@ -46,7 +48,14 @@ def _is_measurable(value: object) -> bool:
)
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
async def _call_worker(
image_bytes: bytes,
graph: dict,
*,
recipe_dir: str | None = None,
calibration: dict | None = None,
worker_url: str | None = None,
) -> dict:
"""The one place that talks to the worker; the tests replace it.
The worker's fault must not become the API's fault (spec's edge-case
@@ -60,22 +69,31 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
is not the worker's JSON contract must not turn into a bare KeyError
further down.
"""
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
# si mandano, il worker usa i suoi default.
data = {"graph": json.dumps(graph)}
# worker della stazione (#45) o, se manca, il centrale; mai un ripiego dall'uno all'altro
base = (worker_url or settings.vision_worker_url).rstrip("/")
if recipe_dir:
data["recipe_dir"] = recipe_dir
if calibration:
data["calibration"] = json.dumps(calibration)
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{settings.vision_worker_url}/run",
f"{base}/run",
files={"image": ("frame.png", image_bytes, "image/png")},
data={"graph": json.dumps(graph)},
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}",
detail=f"the vision worker did not answer in time: {exc} ({base})", # #45
) from exc
except (httpx.TransportError, httpx.HTTPError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc}",
detail=f"the vision worker is unreachable: {exc} ({base})", # #45
) from exc
# The worker already gave this fault a name - forward its status and
@@ -122,6 +140,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,
@@ -132,8 +332,9 @@ async def execute_task(
lot_number: str | None = None,
serial_number: str | None = None,
production_run_id: int | None = None,
) -> tuple[VisionResult, list[Measurement]]:
if task.task_type != "camera_measure":
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",
@@ -155,12 +356,20 @@ async def execute_task(
detail="this recipe is not active",
)
run = None
if production_run_id is not None:
# 404 if the run does not exist, 422 if it belongs to another recipe,
# 409 if it is closed or paused - same rule a manual reading follows.
await measurement_service.validate_run_for_version(
run = await measurement_service.validate_run_for_version(
db, production_run_id, version_id,
)
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
station_url = None
if run is not None:
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
if not task.vision_json:
raise HTTPException(
@@ -210,7 +419,12 @@ 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,
worker_url=station_url, # #45
)
outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse
@@ -231,10 +445,17 @@ 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",
# #45: dove e' girato davvero, e quale worker (copiato come lo snapshot)
executed_on="station" if station_url else "server",
station_id=run.station_id if run is not None else None,
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
graph_snapshot=task.vision_json,
duration_ms=report.get("duration_ms"),
)
@@ -257,7 +478,7 @@ async def execute_task(
enforce_tolerance_gate=False,
))
return result, saved
return result, saved, report
def _reference_file_path(reference: VisionReferenceImage) -> Path:
@@ -377,4 +598,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,
)
+4 -4
View File
@@ -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}
+151 -46
View File
@@ -78,75 +78,114 @@ class TestLogin:
assert len(api_key) >= 32
class TestLoginKeyReuse:
"""login_user reuses an existing api_key instead of rotating it (#25)."""
class TestLoginKeyPerSession:
"""Each login issues its own key; none revokes another (#25, #41)."""
async def test_two_logins_same_user_share_api_key(
async def test_two_logins_same_user_get_distinct_valid_keys(
self, client: AsyncClient, db_session
):
"""Two stations logging in as the same shift account both keep working."""
await _create_user(db_session, username="twostations", password="TwoStations1")
resp1 = await client.post(
"/api/auth/login",
json={"username": "twostations", "password": "TwoStations1"},
)
resp2 = await client.post(
"/api/auth/login",
json={"username": "twostations", "password": "TwoStations1"},
)
assert resp1.status_code == 200
assert resp2.status_code == 200
assert resp1.json()["api_key"] == resp2.json()["api_key"]
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_login_generates_key_when_user_has_none(
async def test_db_stores_hash_never_clear_key(
self, client: AsyncClient, db_session
):
"""A user created without an api_key (e.g. via create_user) still gets one."""
from src.backend.services.auth_service import create_user
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="brandnew", password="BrandNew1",
display_name="Brand New",
)
await _create_user(db_session, username="hashed", password="HashedPass1")
resp = await client.post(
"/api/auth/login",
json={"username": "brandnew", "password": "BrandNew1"},
"/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
assert resp.json()["api_key"]
await db_session.refresh(row)
assert row.expires_at >= before + timedelta(hours=23, minutes=59)
class TestLogout:
"""POST /api/auth/logout is a no-op on the shared api_key by default (#25)."""
"""Logout deletes only this session's key (#41)."""
async def test_logout_default_keeps_api_key_valid(
self, client: AsyncClient, db_session
):
user = await _create_user(
db_session, username="shared_logout", password="SharedPass1"
)
headers = auth_headers(user)
resp = await client.post("/api/auth/logout", headers=headers)
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
# The key is still valid: another station on the same account keeps working.
resp = await client.get("/api/auth/me", headers=headers)
assert resp.status_code == 200
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_invalidates_api_key(
async def test_logout_all_sessions_removes_every_key(
self, client: AsyncClient, db_session
):
user = await _create_user(
db_session, username="shared_logout_all", password="SharedPass1"
)
headers = auth_headers(user)
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=headers
"/api/auth/logout", params={"all_sessions": "true"},
headers={"X-API-Key": k1},
)
assert resp.status_code == 204
resp = await client.get("/api/auth/me", headers=headers)
assert resp.status_code == 401
for k in (k1, k2):
assert (await client.get("/api/auth/me", headers={"X-API-Key": k})).status_code == 401
class TestAuthenticateUserTiming:
@@ -185,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
+147
View File
@@ -107,3 +107,150 @@ class TestBaseMigrationNoopsOnExistingSchema:
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")
class TestStationVisionWorkerMigration:
"""019_station_vision_worker: stations.vision_worker_url e vision_results.worker_url, su e giu (#45)."""
def test_019_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def cols(table):
return {c["name"] for c in _inspect(db_path).get_columns(table)}
command.upgrade(cfg, "head")
assert "vision_worker_url" in cols("stations")
assert "worker_url" in cols("vision_results")
command.downgrade(cfg, "018_task_loop_results")
assert "vision_worker_url" not in cols("stations")
assert "worker_url" not in cols("vision_results")
command.upgrade(cfg, "head") # e di nuovo su
assert "vision_worker_url" in cols("stations")
assert "worker_url" in cols("vision_results")
+83
View File
@@ -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() == []
+175
View File
@@ -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,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
+44
View File
@@ -176,6 +176,50 @@ class TestUpdateSettingsValidation:
)
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
):
+56
View File
@@ -374,3 +374,59 @@ async def test_reset_unknown_station_is_404(client: AsyncClient, admin_user):
"/api/stations/999999/recipes", headers=auth_headers(admin_user),
)
assert resp.status_code == 404
# --- vision_worker_url (#45) ---
async def test_station_vision_worker_url_roundtrip(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-VW", "name": "VW", "vision_worker_url": " http://10.0.0.7:8100/ "},
)
assert created.status_code == 201, created.text
# spazi tolti e "/" finale tolto
assert created.json()["vision_worker_url"] == "http://10.0.0.7:8100"
sid = created.json()["id"]
updated = await client.put(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
json={"vision_worker_url": "https://w.example:9000"},
)
assert updated.status_code == 200
assert updated.json()["vision_worker_url"] == "https://w.example:9000"
# stringa vuota = torna al worker centrale
cleared = await client.put(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
json={"vision_worker_url": ""},
)
assert cleared.status_code == 200
assert cleared.json()["vision_worker_url"] is None
async def test_station_vision_worker_url_defaults_to_null(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-VN", "name": "VN"},
)
assert created.status_code == 201
assert created.json()["vision_worker_url"] is None
async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin_user):
resp = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-VF", "name": "VF", "vision_worker_url": "ftp://x"},
)
assert resp.status_code == 422
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-VG", "name": "VG"},
)
resp = await client.put(
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
json={"vision_worker_url": "ftp://x"},
)
assert resp.status_code == 422
+189
View File
@@ -211,3 +211,192 @@ 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
class TestRunTaskResults:
"""#58: GET /api/statistics/runs/{id}/task-results."""
FIELDS = [
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
]
async def _setup(self, client, db_session, maker_user, tec_user):
from datetime import datetime
from sqlalchemy import select
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task_result import TaskResult
recipe = await create_test_recipe(db_session, maker_user.id, code="TR58")
version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().first()
db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id))
await db_session.commit()
station = (await db_session.execute(select(Station))).scalars().first()
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1),
)
db_session.add(run)
await db_session.commit()
h = auth_headers(maker_user)
check = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h,
json={"title": "Controllo", "task_type": "check"},
)).json()
note = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h,
json={"title": "Nota", "task_type": "note", "fields": self.FIELDS},
)).json()
# Inseriti fuori ordine: l'API deve ordinare per created_at.
rows = [
TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="done", note="ok finito",
values={"peso": "12", "foto": "x.jpg"},
attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}],
created_at=datetime(2026, 1, 1, 10, 3)),
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="nok", note="graffio",
created_at=datetime(2026, 1, 1, 10, 2)),
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="ok",
created_at=datetime(2026, 1, 1, 10, 1)),
# Un esito di un'altra sessione (nessuna) non deve comparire.
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None,
user_id=tec_user.id, outcome="ok",
created_at=datetime(2026, 1, 1, 9, 0)),
]
db_session.add_all(rows)
await db_session.commit()
return recipe, run, check, note
async def test_counts_order_labels_attachments(
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
):
_, run, check, note = await self._setup(
client, db_session, maker_user, measurement_tec_user)
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
headers=auth_headers(metrologist_user))
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["run_id"] == run.id
assert data["counts"] == {"ok": 1, "nok": 1, "done": 1}
res = data["results"]
assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente
assert res[1]["note"] == "graffio"
assert res[1]["task_title"] == "Controllo"
assert res[1]["task_type"] == "check"
assert res[1]["user_name"] == measurement_tec_user.display_name
assert res[1]["values"] == [] and res[1]["attachments"] == []
n = res[2]
assert n["task_title"] == "Nota" and n["note"] == "ok finito"
assert n["values"] == [
{"key": "peso", "label": "Peso", "value": "12"},
{"key": "foto", "label": "Foto", "value": "x.jpg"},
]
assert n["attachments"] == [
{"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"},
]
async def test_label_falls_back_to_key_when_field_removed(
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
):
from src.backend.models.orm.task import RecipeTask
_, run, _, note = await self._setup(
client, db_session, maker_user, measurement_tec_user)
task = await db_session.get(RecipeTask, note["id"])
task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task
await db_session.commit()
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
headers=auth_headers(metrologist_user))
n = resp.json()["results"][2]
assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"}
assert n["attachments"][0]["label"] == "foto"
async def test_unknown_run_is_404(self, client, metrologist_user):
resp = await client.get("/api/statistics/runs/99999/task-results",
headers=auth_headers(metrologist_user))
assert resp.status_code == 404
async def test_measurement_tec_is_403(self, client, measurement_tec_user):
resp = await client.get("/api/statistics/runs/1/task-results",
headers=auth_headers(measurement_tec_user))
assert resp.status_code == 403
+210
View File
@@ -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")
+240
View File
@@ -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
+113
View File
@@ -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"
@@ -17,7 +17,7 @@ class TestChangeUserPassword:
the same as setup.py's change-user-password (setup.py:634).
"""
old_key = maker_user.api_key
old_key = maker_user.plain_key
old_username = maker_user.username
assert old_key is not None
@@ -31,7 +31,7 @@ class TestChangeUserPassword:
# The old key no longer authenticates. Read it before the request
# above: the client and app share one session (see conftest), so
# `maker_user.api_key` in this process reflects the mutation too.
# `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
+2 -2
View File
@@ -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 [],
@@ -316,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,
@@ -53,7 +53,7 @@ async def a_camera_task(db_session, recipe_id: int):
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
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": [],
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr(
@@ -0,0 +1,282 @@
"""Ricetta VisionSuite v2 su un task, execute dxf_compare, copy-on-write (#46)."""
import io
import json
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionResult
from src.backend.tests.conftest import auth_headers, create_test_recipe
RECIPE = {
"schema_version": 2, "name": "dxf", "tools": [],
"meta": {"dxf": {"d1": {"path": "dxf/part.dxf"}}, "models": {"m": {"path": "models/m.npz"}}},
}
OUTPUTS = [{"key": "cmp.points_in_pct", "quantity": "ratio", "unit": "%"}]
GOOD_FILES = (("part.dxf", b"0 EOF"), ("m.npz", b"npz"))
@pytest.fixture(autouse=True)
def redirect_uploads(monkeypatch, tmp_path):
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
@pytest.fixture
def validate_says(monkeypatch):
"""Il worker /validate non gira nei test: risposta finta."""
def _install(ok=True, errors=None, outputs=None):
async def fake(graph, recipe_dir):
return {
"ok": ok, "errors": errors or [],
"outputs": outputs if outputs is not None else OUTPUTS,
}
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
return _install
def an_image() -> bytes:
buf = io.BytesIO()
Image.new("L", (32, 32)).save(buf, format="PNG")
return buf.getvalue()
async def make_task(db_session, user, task_type="dxf_compare"):
recipe = await create_test_recipe(db_session, user.id)
version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().first()
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()
task.task_type = task_type
await db_session.commit()
return task, subtask
def upload(client, user, task, recipe=RECIPE, files=()):
return client.post(
f"/api/vision/tasks/{task.id}/recipe",
headers=auth_headers(user),
files=[("recipe", ("r.json", json.dumps(recipe).encode(), "application/json"))]
+ [("files", (n, c, "application/octet-stream")) for n, c in files],
)
@pytest.mark.asyncio
async def test_upload_ok_stores_folder_and_task(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await upload(client, maker_user, task, files=GOOD_FILES)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["outputs"] == OUTPUTS
assert sorted(body["files"]) == ["dxf/part.dxf", "models/m.npz"]
assert body["recipe_dir"].startswith(f"vision/recipes/{task.id}/")
folder = settings.upload_path / body["recipe_dir"]
assert (folder / "recipe.json").is_file() and (folder / "dxf" / "part.dxf").is_file()
await db_session.refresh(task)
assert task.vision_json == RECIPE and task.vision_recipe_dir == body["recipe_dir"]
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
assert got.status_code == 200
assert got.json()["recipe"] == RECIPE and got.json()["outputs"] == OUTPUTS
@pytest.mark.asyncio
async def test_get_without_recipe_is_empty(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
assert got.json() == {
"task_id": task.id, "recipe_dir": None, "recipe": None, "files": [], "outputs": [],
}
@pytest.mark.asyncio
async def test_unmatched_file_name_is_422_list(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await upload(client, maker_user, task, files=[("other.dxf", b"x")])
assert resp.status_code == 422
assert isinstance(resp.json()["detail"], list) and "other.dxf" in resp.json()["detail"][0]
await db_session.refresh(task)
assert task.vision_json is None and task.vision_recipe_dir is None
@pytest.mark.asyncio
async def test_traversal_in_meta_path_is_422(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
evil = {"schema_version": 2, "tools": [], "meta": {"dxf": {"d": {"path": "../../evil.dxf"}}}}
resp = await upload(client, maker_user, task, recipe=evil, files=[("evil.dxf", b"x")])
assert resp.status_code == 422
assert not (settings.upload_path.parent / "evil.dxf").exists()
@pytest.mark.asyncio
async def test_wrong_task_type_is_422(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user, task_type="measure")
resp = await upload(client, maker_user, task, files=GOOD_FILES)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_invalid_json_is_422(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await client.post(
f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user),
files=[("recipe", ("r.json", b"{nope", "application/json"))],
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_worker_not_ok_is_422_and_task_unchanged(client, db_session, maker_user, validate_says):
validate_says(ok=False, errors=["meta file missing"])
task, _ = await make_task(db_session, maker_user)
task.vision_json = {"old": 1}
task.vision_recipe_dir = "vision/recipes/old"
await db_session.commit()
resp = await upload(client, maker_user, task, files=GOOD_FILES)
assert resp.status_code == 422
assert resp.json()["detail"] == ["meta file missing"]
await db_session.refresh(task)
assert task.vision_json == {"old": 1} and task.vision_recipe_dir == "vision/recipes/old"
@pytest.mark.asyncio
async def test_existing_folder_is_not_rewritten(client, db_session, maker_user, validate_says):
validate_says()
task, _ = await make_task(db_session, maker_user)
first = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
marker = settings.upload_path / first["recipe_dir"] / "dxf" / "part.dxf"
marker.write_bytes(b"CHANGED")
second = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
assert second["recipe_dir"] == first["recipe_dir"]
assert marker.read_bytes() == b"CHANGED"
@pytest.mark.asyncio
async def test_upload_requires_maker(
client, db_session, maker_user, measurement_tec_user, validate_says,
):
validate_says()
task, _ = await make_task(db_session, maker_user)
resp = await upload(client, measurement_tec_user, task, files=GOOD_FILES)
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_execute_dxf_compare_saves_files_and_extended_response(
client, db_session, measurement_tec_user, monkeypatch,
):
task, subtask = await make_task(db_session, measurement_tec_user)
task.vision_json = RECIPE
task.vision_recipe_dir = "vision/recipes/1/abc"
subtask.vision_output = "cmp.points_in_pct"
await db_session.commit()
seen = {}
overlay = [{"type": "contour", "role": "found", "rows": [1], "cols": [2], "tool_id": "cmp"}]
async def fake_call(image_bytes, graph, **kwargs):
seen.update(kwargs)
return {
"outputs": {"cmp.points_in_pct": float(subtask.nominal)},
"failures": [], "engine_version": "e" * 40, "duration_ms": 5,
"overlay": overlay, "outcome": "OK",
}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = 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 resp.status_code == 200, resp.text
body = resp.json()
assert seen["recipe_dir"] == "vision/recipes/1/abc" and "calibration" not in seen
assert body["outcome"] == "OK" and body["overlay"] == overlay and body["failures"] == []
assert body["image_url"] == f"/api/vision/results/{body['vision_result_id']}/image"
assert body["measurements"][0]["pass_fail"] == "pass"
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert (settings.upload_path / result.image_path).read_bytes() == an_image()
assert json.loads((settings.upload_path / result.overlay_path).read_text()) == overlay
img = await client.get(body["image_url"], headers=auth_headers(measurement_tec_user))
assert img.status_code == 200 and img.content == an_image()
@pytest.mark.asyncio
async def test_execute_dxf_compare_out_of_tolerance_fails(
client, db_session, measurement_tec_user, monkeypatch,
):
task, subtask = await make_task(db_session, measurement_tec_user)
task.vision_json = RECIPE
subtask.vision_output = "cmp.points_in_pct"
await db_session.commit()
async def fake_call(image_bytes, graph, **kwargs):
return {"outputs": {"cmp.points_in_pct": 99.0}, "failures": [],
"engine_version": "e" * 40, "outcome": "OK"}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = 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 resp.status_code == 200
# il verdetto e' delle tolleranze del subtask, non di outcome di VisionSuite
assert resp.json()["measurements"][0]["pass_fail"] == "fail"
assert resp.json()["overlay"] == []
@pytest.mark.asyncio
async def test_copy_on_write_carries_recipe_dir(client, db_session, maker_user):
from src.backend.tests.test_vision_copy_on_write import (
_current_task_and_subtask, _seed_measurement,
)
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
task.task_type = "dxf_compare"
task.vision_json = RECIPE
task.vision_recipe_dir = "vision/recipes/1/abc"
await db_session.flush()
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
resp = await client.put(
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
json={"change_notes": "cow"},
)
assert resp.status_code == 200
db_session.expunge_all()
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
assert version.version_number == 2
assert new_task.vision_recipe_dir == "vision/recipes/1/abc" and new_task.vision_json == RECIPE
@pytest.mark.asyncio
async def test_retry_with_missing_file_then_complete_succeeds(
client, db_session, maker_user, monkeypatch,
):
# worker finto: ok solo se il DXF esiste nella cartella della ricetta
async def fake(graph, recipe_dir):
present = (settings.upload_path / recipe_dir / "dxf" / "part.dxf").is_file()
return {"ok": present, "errors": [] if present else ["dxf missing"], "outputs": OUTPUTS}
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
task, _ = await make_task(db_session, maker_user)
first = await upload(client, maker_user, task, files=[])
assert first.status_code == 422
second = await upload(client, maker_user, task, files=GOOD_FILES)
assert second.status_code == 200, second.text
+1 -1
View File
@@ -39,7 +39,7 @@ async def recipe_parts(db_session, recipe_id: int):
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
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": [],
"engine_version": "b" * 40, "duration_ms": 7}
monkeypatch.setattr(
@@ -0,0 +1,199 @@
"""#45: il worker di visione si sceglie per stazione.
La stazione si legge dalla sessione di produzione (`production_run_id`): con un
URL sulla stazione `_call_worker` lo riceve e il VisionResult lo registra;
senza URL o senza run si resta sul worker centrale, senza ripiego fra i due.
"""
import io
import httpx
import pytest
from fastapi import HTTPException
from PIL import Image
from src.backend.config import settings
from src.backend.models.api.production import ProductionRunCreate
from src.backend.models.orm.station import Station
from src.backend.models.orm.vision import VisionResult
from src.backend.services import production_service
from src.backend.services.vision_service import _call_worker
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_execute import recipe_parts
from sqlalchemy import select
STATION_URL = "http://10.0.0.7:8100"
def an_image() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
return buffer.getvalue()
@pytest.fixture
def worker_calls(monkeypatch):
"""Fake `_call_worker` che ricorda i kwargs con cui e' stato chiamato."""
calls: list[dict] = []
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
calls.append(kwargs)
return {"outputs": {"t1.diameter": 10.0}, "failures": [],
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
return calls
async def _camera_task(db_session, user_id: int):
recipe = await create_test_recipe(db_session, user_id)
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()
return recipe, task
async def _station_with_run(db_session, user, recipe, worker_url):
station = Station(
code="ST-W", name="Stazione W", active=True, created_by=user.id,
vision_worker_url=worker_url,
)
db_session.add(station)
await db_session.commit()
await db_session.refresh(station)
run = await production_service.open_run(
db_session,
ProductionRunCreate(station_code=station.code, recipe_id=recipe.id),
user,
)
await db_session.commit()
return station, run
async def _execute(client, user, task, run=None):
data = {"task_id": str(task.id)}
if run is not None:
data["production_run_id"] = str(run.id)
response = await client.post(
"/api/vision/execute",
headers=auth_headers(user),
files={"image": ("p.png", an_image(), "image/png")},
data=data,
)
assert response.status_code == 200, response.text
async def _the_result(db_session) -> VisionResult:
return (await db_session.execute(select(VisionResult))).scalars().one()
@pytest.mark.asyncio
async def test_station_with_url_gets_the_call_and_the_record(
client, db_session, measurement_tec_user, worker_calls,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(
db_session, measurement_tec_user, recipe, STATION_URL)
await _execute(client, measurement_tec_user, task, run)
assert worker_calls[0]["worker_url"] == STATION_URL
result = await _the_result(db_session)
assert result.executed_on == "station"
assert result.worker_url == STATION_URL
assert result.station_id == station.id
@pytest.mark.asyncio
async def test_station_without_url_uses_the_central_worker(
client, db_session, measurement_tec_user, worker_calls,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(
db_session, measurement_tec_user, recipe, None)
await _execute(client, measurement_tec_user, task, run)
assert worker_calls[0]["worker_url"] is None
result = await _the_result(db_session)
assert result.executed_on == "server"
assert result.worker_url == settings.vision_worker_url.rstrip("/")
assert result.station_id == station.id
@pytest.mark.asyncio
async def test_without_a_run_the_central_worker_is_used(
client, db_session, measurement_tec_user, worker_calls,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
await _execute(client, measurement_tec_user, task)
assert worker_calls[0]["worker_url"] is None
result = await _the_result(db_session)
assert result.executed_on == "server"
assert result.worker_url == settings.vision_worker_url.rstrip("/")
assert result.station_id is None
class _Response:
status_code = 200
def json(self):
return {"outputs": {}, "failures": [], "engine_version": "e" * 40}
class _RecordingClient:
"""httpx.AsyncClient finto: registra gli URL postati, opzionalmente fallisce."""
def __init__(self, posts: list, raises=None):
self._posts = posts
self._raises = raises
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def post(self, url, *args, **kwargs):
self._posts.append(url)
if self._raises is not None:
raise self._raises
return _Response()
def _install(monkeypatch, raises=None) -> list:
posts: list = []
monkeypatch.setattr(
"src.backend.services.vision_service.httpx.AsyncClient",
lambda *a, **k: _RecordingClient(posts, raises),
)
return posts
@pytest.mark.asyncio
async def test_call_worker_posts_to_the_given_url(monkeypatch):
posts = _install(monkeypatch)
# lo "/" finale non deve produrre "//run"
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL + "/")
assert posts == [f"{STATION_URL}/run"]
@pytest.mark.asyncio
async def test_call_worker_defaults_to_the_central_url(monkeypatch):
posts = _install(monkeypatch)
await _call_worker(b"img", {"tools": []})
assert posts == [f"{settings.vision_worker_url.rstrip('/')}/run"]
@pytest.mark.asyncio
async def test_station_worker_down_is_502_with_its_url_and_no_fallback(monkeypatch):
posts = _install(monkeypatch, raises=httpx.ConnectError("refused"))
with pytest.raises(HTTPException) as exc:
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL)
assert exc.value.status_code == 502
assert STATION_URL in exc.value.detail
# una sola POST: il centrale non viene tentato
assert posts == [f"{STATION_URL}/run"]
@@ -69,7 +69,7 @@ async def recipe_parts(db_session, recipe_id: int):
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
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": [],
"engine_version": "c" * 40, "duration_ms": 5}
monkeypatch.setattr(
View File
+34
View File
@@ -0,0 +1,34 @@
"""Time helpers: the DB holds naive UTC, presentation converts to display_timezone (#42)."""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from src.backend.config import settings
def utcnow() -> datetime:
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def as_utc_naive(dt: datetime | None) -> datetime | None:
"""Aware -> UTC naive; naive is taken as UTC already; None stays None."""
if dt is None or dt.tzinfo is None:
return dt
return dt.astimezone(timezone.utc).replace(tzinfo=None)
def to_utc_aware(dt: datetime) -> datetime:
"""Naive (UTC) or aware datetime -> aware UTC."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def iso_utc(dt: datetime | None) -> str:
"""ISO 8601 with explicit +00:00; "" for None (CSV cells)."""
return to_utc_aware(dt).isoformat() if dt else ""
def to_local(dt: datetime) -> datetime:
"""Naive UTC (or aware) datetime -> aware datetime in settings.display_timezone."""
return to_utc_aware(dt).astimezone(ZoneInfo(settings.display_timezone))
+24 -1
View File
@@ -2,7 +2,8 @@
import json
import os
import re
from datetime import date
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from urllib.parse import urlparse
from flask import Flask, flash, g, jsonify, redirect, url_for, session, request
@@ -73,12 +74,14 @@ def create_app() -> Flask:
from blueprints.maker import maker_bp
from blueprints.statistics import statistics_bp
from blueprints.admin import admin_bp
from blueprints.monitor import monitor_bp # #57 live station monitor
app.register_blueprint(auth_bp)
app.register_blueprint(measure_bp, url_prefix="/measure")
app.register_blueprint(maker_bp, url_prefix="/maker")
app.register_blueprint(statistics_bp, url_prefix="/statistics")
app.register_blueprint(admin_bp, url_prefix="/admin")
app.register_blueprint(monitor_bp, url_prefix="/monitor") # #57
@app.before_request
def _check_secret_key():
@@ -154,6 +157,26 @@ def create_app() -> Flask:
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
return Markup(bolded.replace("\n", "<br>"))
@app.template_filter("local_dt")
def local_dt_filter(value, fmt="%d/%m/%Y %H:%M"):
"""Show a UTC datetime (ISO string or datetime) in DISPLAY_TIMEZONE.
A value without timezone is taken as UTC. None or empty gives "-".
"""
if value is None or value == "":
return "-"
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return value
if not isinstance(value, datetime):
return str(value)
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
tz = ZoneInfo(app.config.get("DISPLAY_TIMEZONE") or "Europe/Rome")
return value.astimezone(tz).strftime(fmt)
@app.template_filter("tojson_attr")
def tojson_attr_filter(value):
"""JSON encode safe for HTML attributes (x-data, etc.).
+32 -15
View File
@@ -29,28 +29,30 @@ def admin_required(f):
@login_required
@admin_required
def user_list():
"""User management page."""
resp = api_client.get("/api/users")
if isinstance(resp, dict) and resp.get("error"):
flash(_("Errore nel caricamento degli utenti: %(error)s", error=resp.get("detail", "")), "error")
users = []
elif isinstance(resp, list):
users = resp
else:
users = []
return render_template("admin/users.html", users=users)
"""#50: la gestione utenti vive nella tab «Utenti» di Impostazioni."""
return redirect(url_for("admin.settings_page", tab="utenti"))
@admin_bp.route("/settings")
@login_required
@admin_required
def settings_page():
"""System settings page."""
"""System settings page (admin only), con la tab «Utenti» (#50)."""
resp = api_client.get("/api/settings")
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
return render_template("admin/settings.html", settings=settings)
# Solo un dict valido sono impostazioni: un errore o altro tipo = nessuna
settings = resp if isinstance(resp, dict) and not resp.get("error") else {}
users_resp = api_client.get("/api/users")
if isinstance(users_resp, dict) and users_resp.get("error"):
flash(_("Errore nel caricamento degli utenti: %(error)s", error=users_resp.get("detail", "")), "error")
users = []
elif isinstance(users_resp, list):
users = users_resp
else:
users = []
tab = "utenti" if request.args.get("tab") == "utenti" else "generale"
return render_template("admin/settings.html", settings=settings, users=users, active_tab=tab)
@admin_bp.route("/api/settings", methods=["PUT"])
@@ -136,6 +138,21 @@ def api_change_password(user_id: int):
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/pin", methods=["PUT"])
@login_required
@admin_required
def api_set_pin(user_id: int):
"""Proxy: set (4 digits) or remove ({"pin": null}) a user's PIN (#59)."""
data = request.get_json(silent=True) or {}
# Forward only the pin field; backend answers 422 for admins / malformed PINs.
resp = api_client.put(f"/api/users/{user_id}/pin", data={"pin": data.get("pin")})
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
@login_required
@admin_required
+89 -29
View File
@@ -59,6 +59,45 @@ def _post_login_landing():
return url_for("auth.profile")
def _complete_login(response, fallback_name):
"""Dopo una risposta di login riuscita: sessione, preferenze, impostazioni (#59).
Comune al login con password e al login con PIN, cosi' i due restano identici.
Ritorna l'utente salvato in sessione.
"""
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
# limite configurato non ha mai effetto (#33).
session.permanent = True
session["api_key"] = response["api_key"]
session["user"] = response["user"]
session["user_id"] = response["user"]["id"]
# Applica preferenze utente
user = response["user"]
if user.get("language_pref"):
session["language"] = user["language_pref"]
if user.get("theme_pref"):
session["theme"] = user["theme_pref"]
# Carica impostazioni di sistema
settings_resp = api_client.get("/api/settings")
if not settings_resp.get("error"):
logo_path = settings_resp.get("company_logo_path")
if logo_path:
session["company_logo"] = logo_path
auto_logout = settings_resp.get("auto_logout_minutes")
if auto_logout:
try:
session["auto_logout_minutes"] = int(auto_logout)
except (ValueError, TypeError):
pass
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", fallback_name)), "success")
return user
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
"""Login page and form handler."""
@@ -81,35 +120,7 @@ def login():
flash(response.get("detail", _("Credenziali non valide")), "error")
return render_template("auth/login.html")
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
# (8h, config.py): senza, Flask usa una sessione di durata browser e il
# limite configurato non ha mai effetto (#33).
session.permanent = True
session["api_key"] = response["api_key"]
session["user"] = response["user"]
session["user_id"] = response["user"]["id"]
# Applica preferenze utente
user = response["user"]
if user.get("language_pref"):
session["language"] = user["language_pref"]
if user.get("theme_pref"):
session["theme"] = user["theme_pref"]
# Carica impostazioni di sistema
settings_resp = api_client.get("/api/settings")
if not settings_resp.get("error"):
logo_path = settings_resp.get("company_logo_path")
if logo_path:
session["company_logo"] = logo_path
auto_logout = settings_resp.get("auto_logout_minutes")
if auto_logout:
try:
session["auto_logout_minutes"] = int(auto_logout)
except (ValueError, TypeError):
pass
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
user = _complete_login(response, username)
# Redirect dopo login (in base al ruolo)
return redirect(_post_login_landing())
@@ -121,6 +132,55 @@ def login():
return render_template("auth/login.html")
@auth_bp.route("/pin-users", methods=["GET"])
def pin_users():
"""Elenco nomi per la vista Operatore del login (#59): solo id e nome.
Pubblica come il login; se il backend non risponde o non ha il PIN, lista
vuota e la pagina apre il form password.
"""
if session.get("api_key"):
return jsonify([])
try:
resp = api_client.get("/api/auth/pin-users")
except Exception:
return jsonify([])
if not isinstance(resp, list):
return jsonify([])
return jsonify([
{"id": u.get("id"), "display_name": u.get("display_name", "")}
for u in resp if isinstance(u, dict)
])
@auth_bp.route("/login-pin", methods=["POST"])
def login_pin():
"""Login con nome + PIN (#59). JSON in/out; CSRF come il login password
(header X-CSRFToken, gestito da CSRFProtect)."""
data = request.get_json(silent=True) or {}
user_id = data.get("user_id")
pin = str(data.get("pin", ""))
if not isinstance(user_id, int) or not (len(pin) == 4 and pin.isdigit()):
return jsonify({"error": True, "status_code": 422, "detail": _("Il PIN deve essere di 4 cifre")}), 422
try:
response = api_client.post("/api/auth/login-pin", data={"user_id": user_id, "pin": pin})
except Exception as e:
return jsonify({"error": True, "status_code": 502,
"detail": _("Errore di connessione al server: %(error)s", error=str(e))}), 502
if response.get("error"):
status = response.get("status_code") or 401
if status == 401:
detail = _("PIN errato")
else:
detail = response.get("detail") or _("Credenziali non valide")
return jsonify({"error": True, "status_code": status, "detail": detail}), status
_complete_login(response, "") # include il flash di benvenuto
return jsonify({"ok": True, "redirect": _post_login_landing()})
@auth_bp.route("/logout", methods=["POST"])
def logout():
"""Clear session and redirect to login."""
+108 -2
View File
@@ -1,4 +1,7 @@
"""Maker blueprint - recipe creation and editing."""
import json
import math
import requests as http_requests
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
@@ -57,7 +60,20 @@ def recipe_list():
@role_required("Maker")
def recipe_new():
"""Create new recipe."""
return render_template("maker/recipe_editor.html", recipe=None)
# #49: loop duration pre-filled from the system default (new recipes only);
# missing/empty/invalid key -> None -> behaviour unchanged
default_interval = None
settings = api_client.get("/api/settings")
if isinstance(settings, dict) and not settings.get("error"):
try:
value = int(str(settings.get("default_measurement_interval_minutes", "")).strip())
if 1 <= value <= 1440:
default_interval = value
except ValueError:
pass
return render_template(
"maker/recipe_editor.html", recipe=None, default_interval=default_interval
)
@maker_bp.route("/recipes/<int:recipe_id>/edit")
@@ -105,7 +121,41 @@ def task_editor(recipe_id: int):
else:
recipe["tasks"] = []
return render_template("maker/task_editor.html", recipe=recipe)
# #47/#48: liste e tolleranze generali dalle Impostazioni; chiave assente,
# vuota o non valida -> None -> il template usa le voci scritte a mano
settings = api_client.get("/api/settings")
if not isinstance(settings, dict) or settings.get("error"):
settings = {}
return render_template(
"maker/task_editor.html",
recipe=recipe,
list_measurement_types=_settings_string_list(settings.get("list_measurement_types")),
list_units=_settings_string_list(settings.get("list_units")),
general_tolerance_plus=_settings_tolerance(settings.get("general_tolerance_plus")),
general_tolerance_minus=_settings_tolerance(settings.get("general_tolerance_minus")),
)
def _settings_string_list(value):
"""#47: array JSON di stringhe non vuote dalle Impostazioni, altrimenti None."""
try:
items = json.loads(value) if isinstance(value, str) else None
except ValueError:
return None
if not isinstance(items, list):
return None
items = [i.strip() for i in items if isinstance(i, str) and i.strip()]
return items or None
def _settings_tolerance(value):
"""#48: tolleranza generale (decimale >= 0) dalle Impostazioni, altrimenti None."""
try:
number = float(str(value).strip())
except ValueError:
return None
return number if math.isfinite(number) and number >= 0 else None
@maker_bp.route("/recipes/<int:recipe_id>/tasks/<int:task_id>/drawing")
@@ -201,6 +251,21 @@ def api_create_recipe():
return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>/duplicate", methods=["POST"])
@login_required
@role_required("Maker")
def api_duplicate_recipe(recipe_id: int):
"""Proxy: Duplicate a recipe under a new code/name (#52)."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/recipes/{recipe_id}/duplicate", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
@login_required
@role_required("Maker")
@@ -377,6 +442,47 @@ def api_upload_file():
return jsonify(resp), 201
# ============================================================================
# VISION RECIPE PROXY (#46)
# ============================================================================
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["GET"])
@login_required
@role_required("Maker")
def api_get_vision_recipe(task_id: int):
"""Proxy: ricetta visione corrente del task (cartella, file, uscite)."""
resp = api_client.get(f"/api/vision/tasks/{task_id}/recipe")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe", methods=["POST"])
@login_required
@role_required("Maker")
def api_upload_vision_recipe(task_id: int):
"""Proxy: carica ricetta .json + file allegati (multipart forward)."""
recipe = request.files.get("recipe")
if recipe is None or recipe.filename == "":
return jsonify({"error": True, "detail": _("Nessun file ricetta caricato")}), 400
# Ricetta + 0..n allegati (DXF, modelli .npz, calibrazione), stesso nome campo `files`
files = [("recipe", (recipe.filename, recipe.stream, recipe.content_type))]
for f in request.files.getlist("files"):
if f.filename:
files.append(("files", (f.filename, f.stream, f.content_type)))
resp = api_client.post(f"/api/vision/tasks/{task_id}/recipe", data={}, files=files)
# Il 422 (errori di validazione) arriva al browser com'è, con `errors`
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required
def api_get_file(file_path: str):
+230 -33
View File
@@ -1,9 +1,11 @@
"""MeasurementTec blueprint - recipe selection and measurement execution."""
import urllib.parse
import requests as http_requests
from flask import (
Blueprint, abort, flash, jsonify, redirect, render_template,
request, session, url_for,
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
g, request, session, url_for,
)
from flask_babel import gettext as _
@@ -652,6 +654,74 @@ def api_pending_authorisation():
return jsonify(resp), 200
def _supervisor_credentials(data: dict):
"""The capoturno's credentials from the request body (#59).
Two ways in: ``supervisor_user_id`` + ``supervisor_pin`` (name and 4-digit PIN
from the touch pad) or, as before, ``supervisor_username`` +
``supervisor_password``. Returns ``(fields, None)`` with the pair to forward,
or ``(None, error_response)`` when neither pair is complete.
"""
pin = str(data.get("supervisor_pin") or "").strip()
user_id = data.get("supervisor_user_id")
if pin and user_id not in (None, ""):
try:
return {"supervisor_user_id": int(user_id), "supervisor_pin": pin}, None
except (TypeError, ValueError):
pass # user_id not a number: falls through to the missing-credentials answer
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if username and password:
return {"supervisor_username": username, "supervisor_password": password}, None
return None, (jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400)
def _post_supervised(endpoint: str, creds: dict, extra: dict | None = None):
"""POST an action carrying the capoturno's credentials, forwarding the answer.
api_client reads every 401 as "your API key is dead" and empties the Flask
session. For these endpoints a 401 can also be the capoturno's wrong PIN or
password, and that must not throw the operator out of their session (#59):
when the detail names the supervisor or the PIN, the session is put back and
the ``session_expired`` flag is dropped so the modal can say "PIN errato".
"""
saved = dict(session)
resp = api_client.post(endpoint, data={**creds, **(extra or {})})
if isinstance(resp, dict) and resp.get("error"):
detail = str(resp.get("detail") or "").lower()
if resp.get("status_code") == 401 and ("supervisor" in detail or "pin" in detail):
session.update(saved) # the 401 was about the capoturno, not about this session
g.session_expired = False
resp = {k: v for k, v in resp.items() if k != "session_expired"}
if "supervisor_pin" in creds:
resp["detail"] = _("PIN errato") # the backend's English text is not for the operator
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/pin-users", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_pin_users():
"""Proxy: names for the capoturno's PIN pad (#59), id and display name only.
Empty list when the backend does not answer, so the modal falls back to the
password form instead of showing a pad nobody can use.
"""
try:
resp = api_client.get("/api/auth/pin-users")
except Exception:
return jsonify([])
if not isinstance(resp, list):
return jsonify([])
return jsonify([
{"id": u.get("id"), "display_name": u.get("display_name", "")}
for u in resp if isinstance(u, dict)
])
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
@login_required
@role_required("MeasurementTec")
@@ -663,20 +733,10 @@ def api_authorise_measurement(measurement_id: int):
written down nowhere and nothing depending on it.
"""
data = request.get_json(silent=True) or {}
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if not username or not password:
return jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400
resp = api_client.post(
f"/api/measurements/{measurement_id}/authorise",
data={"supervisor_username": username, "supervisor_password": password},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
creds, error = _supervisor_credentials(data) # #59: PIN or password
if error:
return error
return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds)
# ---------------------------------------------------------------------------
@@ -768,24 +828,12 @@ def _supervised_action(run_id: int, action: str):
leave no such record, and is an extra round trip besides.
"""
data = request.get_json(silent=True) or {}
username = (data.get("supervisor_username") or "").strip()
password = data.get("supervisor_password") or ""
if not username or not password:
return jsonify({
"error": True, "detail": _("Username e password richiesti"),
}), 400
resp = api_client.post(
f"/api/production-runs/{run_id}/{action}",
data={
"supervisor_username": username,
"supervisor_password": password,
"note": data.get("note"),
},
creds, error = _supervisor_credentials(data) # #59: PIN or password
if error:
return error
return _post_supervised(
f"/api/production-runs/{run_id}/{action}", creds, {"note": data.get("note")},
)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
@@ -812,6 +860,91 @@ def api_close_production(run_id: int):
return _supervised_action(run_id, "close")
# ---------------------------------------------------------------------------
# Routes: task results (#53 #54 #55) - outcome of a "check" task and the values /
# attachments of a "note" task with fields. The browser cannot send X-API-Key,
# so each call goes through here.
# ---------------------------------------------------------------------------
def _relay(resp, ok_status: int = 200):
"""JSON back to the browser: backend errors keep their status and detail.
The detail is what the operator reads («Per un NOK serve la nota»), so it is
passed through as it came, never replaced by a generic message.
"""
if isinstance(resp, dict) and resp.get("error"):
body = {"error": True, "detail": resp.get("detail", _("Errore nel salvataggio"))}
if resp.get("session_expired"):
body["session_expired"] = True
status_code = resp.get("status_code", 500)
return jsonify(body), status_code if status_code >= 400 else 500
return jsonify(resp), ok_status
@measure_bp.route("/api/tasks/<int:task_id>", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_get_task(task_id: int):
"""Proxy: a task with its fields (#55: the tasks to fill at the end of production)."""
return _relay(api_client.get(f"/api/tasks/{task_id}"))
@measure_bp.route("/api/task-results", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_save_task_result():
"""Proxy: record the result of a check / note task (#54, #55)."""
data = request.get_json(silent=True) or {}
if data.get("task_id") is None:
return jsonify({
"error": True, "detail": _("Dati mancanti: task_id è obbligatorio"),
}), 400
payload = {
"task_id": data.get("task_id"),
"production_run_id": data.get("production_run_id"),
"outcome": data.get("outcome"),
"note": data.get("note"),
"values": data.get("values"),
"attachments": data.get("attachments"),
}
return _relay(api_client.post("/api/task-results", data=payload), 201)
@measure_bp.route("/api/task-results", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_list_task_results():
"""Proxy: results already recorded, filtered by production run and/or task."""
params = {
key: request.args[key]
for key in ("production_run_id", "task_id")
if request.args.get(key)
}
return _relay(api_client.get("/api/task-results", params=params))
@measure_bp.route("/api/attachments", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_upload_attachment():
"""Proxy: upload an attachment of a note task (image or PDF), multipart.
Answers with the backend's reply: the `file_path` in it is what goes into the
`attachments` of the result.
"""
upload = request.files.get("file")
if upload is None or not upload.filename:
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
resp = api_client.post(
"/api/task-results/attachments", # #55: upload per l operatore (files/upload e solo Maker)
files={"file": (
upload.filename,
upload.read(),
upload.mimetype or "application/octet-stream",
)},
)
return _relay(resp, 201)
# ---------------------------------------------------------------------------
# Route: File proxy (browser can't send X-API-Key directly)
# ---------------------------------------------------------------------------
@@ -820,3 +953,67 @@ def api_close_production(run_id: int):
def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
return proxy_file(file_path)
# ---------------------------------------------------------------------------
# Route: vision execute (#46) - the browser sends an image, we relay it to
# POST /api/vision/execute (multipart) because it cannot send X-API-Key itself.
# ---------------------------------------------------------------------------
@measure_bp.route("/api/vision/execute", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def api_vision_execute():
"""Proxy: run the task's VisionSuite recipe on the uploaded image."""
image = request.files.get("image")
task_id = request.form.get("task_id")
if image is None or not task_id:
return jsonify({
"error": True,
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
}), 400
# Same context fields as the manual flow; empty ones are left out so the
# backend sees them as absent rather than as "".
form = {"task_id": task_id}
for key in ("task_type", "lot_number", "serial_number", "production_run_id"):
val = request.form.get(key)
if val:
form[key] = val
form.setdefault("lot_number", session.get("lot_number", ""))
form.setdefault("serial_number", session.get("serial_number", ""))
resp = api_client.post(
"/api/vision/execute",
data=form,
files={"image": (
image.filename or "capture.jpg",
image.read(),
image.mimetype or "application/octet-stream",
)},
)
if resp.get("error"):
status_code = resp.get("status_code", 500)
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
if resp.get("session_expired"):
body["session_expired"] = True
return jsonify(body), status_code if status_code >= 400 else 500
return jsonify(resp), 200
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_vision_result_image(result_id: int):
"""Proxy: the image saved with a vision result (needs X-API-Key)."""
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/vision/results/{result_id}/image",
headers={"X-API-Key": session.get("api_key", "")},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
@@ -0,0 +1,45 @@
"""Live monitor of the open production runs (#57).
Page at /monitor for admin and Supervisor (capoturno); the page polls a JSON proxy
of GET /api/production-runs/open every 10 seconds.
"""
from functools import wraps
from flask import Blueprint, abort, jsonify, render_template, session
from blueprints.auth import login_required
from services.api_client import api_client
monitor_bp = Blueprint("monitor", __name__)
def monitor_required(f):
"""Admin or Supervisor only: the same people the backend lets read /open."""
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
if not (user.get("is_admin") or "Supervisor" in user.get("roles", [])):
abort(403)
return f(*args, **kwargs)
return decorated
@monitor_bp.route("", strict_slashes=False)
@login_required
@monitor_required
def index():
return render_template("monitor/index.html")
@monitor_bp.route("/api/runs")
@login_required
@monitor_required
def api_runs():
"""JSON proxy polled by the page."""
resp = api_client.get("/api/production-runs/open")
if isinstance(resp, dict) and resp.get("error"):
body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
body["session_expired"] = True
return jsonify(body), resp.get("status_code", 500)
return jsonify(resp)
@@ -1,12 +1,13 @@
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
import requests as http_requests
from flask import Blueprint, jsonify, render_template, request, session
from flask import Blueprint, abort, jsonify, render_template, request, session
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client
from services.file_proxy import proxy_file # #58: allegati dei task
statistics_bp = Blueprint("statistics", __name__)
@@ -50,7 +51,7 @@ def _proxy_statistics(endpoint: str):
for key in [
"recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id",
"lot_number", "serial_number", "n_bins",
"lot_number", "serial_number", "n_bins", "production_run_id", # #58
]:
val = request.args.get(key)
if val is not None and val != "":
@@ -127,6 +128,54 @@ def api_subtasks():
return jsonify(resp)
@statistics_bp.route("/api/runs")
@login_required
@role_required("Metrologist")
def api_runs():
"""Proxy: production runs of a recipe (#58, "Sessione" filter)."""
params = {}
recipe_id = request.args.get("recipe_id")
if recipe_id:
params["recipe_id"] = recipe_id
resp = api_client.get("/api/statistics/runs", params=params)
if isinstance(resp, dict) and resp.get("error"):
error_body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
error_body["session_expired"] = True
return jsonify(error_body), resp.get("status_code", 500)
return jsonify(resp)
@statistics_bp.route("/api/runs/<int:run_id>/task-results")
@login_required
@role_required("Metrologist")
def api_run_task_results(run_id: int):
"""Proxy: esiti, note, campi e allegati dei task di una sessione (#58)."""
resp = api_client.get(f"/api/statistics/runs/{run_id}/task-results")
if isinstance(resp, dict) and resp.get("error"):
error_body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
error_body["session_expired"] = True
return jsonify(error_body), resp.get("status_code", 500)
return jsonify(resp)
@statistics_bp.route("/api/files/<path:file_path>")
@login_required
@role_required("Metrologist")
def api_task_result_file(file_path: str):
"""Proxy: allegati dei task (#58). Solo task_results/, mai altri file."""
# #58: il Metrologist non deve poter leggere altro dell'upload dir (es. statistics/).
if not file_path.startswith("task_results/") or ".." in file_path.split("/"):
abort(404)
return proxy_file(file_path)
# ============================================================================
# PROXY REPORT PDF → FastAPI
# ============================================================================
@@ -137,7 +186,7 @@ def _proxy_report(endpoint: str):
for key in [
"recipe_id", "version_id", "subtask_id",
"date_from", "date_to", "operator_id",
"lot_number", "serial_number",
"lot_number", "serial_number", "production_run_id", # #58
]:
val = request.args.get(key)
if val is not None and val != "":
+2
View File
@@ -56,6 +56,8 @@ class Config:
# Babel i18n
BABEL_DEFAULT_LOCALE = "it"
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
# Il DB e le API parlano UTC; si converte solo alla presentazione.
DISPLAY_TIMEZONE: str = os.getenv("DISPLAY_TIMEZONE", "Europe/Rome")
LANGUAGES = {"it": "Italiano", "en": "English"}
@@ -271,7 +271,7 @@ function csvExport() {
*/
generateFilename(prefix) {
const now = new Date();
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // YYYY-MM-DD locale
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
return `${prefix}_${dateStr}_${timeStr}.csv`;
@@ -0,0 +1,293 @@
/**
* image-crop.js — ritaglio di immagine/PDF prima dell'upload (#56).
*
* Cosa fa: ImageCrop.open(file) apre una modale con un rettangolo di ritaglio
* trascinabile e ridimensionabile (mouse e touch, Pointer Events). Per un PDF
* rasterizza la pagina scelta con pdf.js (>= 2000 px sul lato lungo).
* Risolve: Blob PNG del ritaglio (a piena risoluzione) | il file originale
* ("Usa originale") | null ("Annulla").
* Come si usa: ImageCrop.configure({pdfSrc, workerSrc, labels}) una volta dal
* template (URL di vendor/pdf e stringhe tradotte), poi `await ImageCrop.open(file)`.
* Dipendenze: nessuna obbligatoria. pdf.js (gia' vendorizzato) viene caricato
* al bisogno solo per i PDF; il ritaglio usa un canvas 2D semplice invece di
* fabric.js perche' serve solo un rettangolo, non un editor di oggetti.
*/
(function (global) {
'use strict';
var cfg = {
pdfSrc: '',
workerSrc: '',
labels: {
title: 'Ritaglia immagine',
page: 'Pagina',
hint: 'Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.',
pdfNote: 'Un PDF ritagliato viene salvato come immagine PNG.',
confirm: 'Conferma ritaglio',
original: 'Usa originale',
cancel: 'Annulla',
loading: 'Caricamento...',
error: 'Impossibile aprire il file per il ritaglio.'
}
};
var MIN_SIDE = 16; // lato minimo del ritaglio, in px immagine
var HANDLE = 12; // raggio di presa delle maniglie, in px schermo
var PDF_MIN_LONG = 2000; // lato lungo minimo del raster PDF
var PDF_MAX_LONG = 6000; // tetto per non esaurire la memoria del canvas
function configure(opts) {
if (!opts) return;
if (opts.pdfSrc) cfg.pdfSrc = opts.pdfSrc;
if (opts.workerSrc) cfg.workerSrc = opts.workerSrc;
if (opts.labels) for (var k in opts.labels) cfg.labels[k] = opts.labels[k];
}
function isPdf(file) {
return file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '');
}
function loadPdfJs() {
if (global.pdfjsLib) return Promise.resolve(global.pdfjsLib);
if (!cfg.pdfSrc) return Promise.reject(new Error('pdf.js non configurato'));
return new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = cfg.pdfSrc;
s.onload = function () { resolve(global.pdfjsLib); };
s.onerror = function () { reject(new Error('pdf.js non caricato')); };
document.head.appendChild(s);
});
}
function readImage(file) {
return new Promise(function (resolve, reject) {
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('immagine illeggibile')); };
img.src = url;
});
}
function renderPdfPage(pdf, n) {
return pdf.getPage(n).then(function (page) {
var base = page.getViewport({ scale: 1 });
var long = Math.max(base.width, base.height);
var scale = Math.min(PDF_MAX_LONG / long, Math.max(2, PDF_MIN_LONG / long));
var vp = page.getViewport({ scale: scale });
var c = document.createElement('canvas');
c.width = Math.round(vp.width);
c.height = Math.round(vp.height);
return page.render({ canvasContext: c.getContext('2d'), viewport: vp }).promise
.then(function () { return c; });
});
}
function el(tag, cls, text) {
var e = document.createElement(tag);
if (cls) e.className = cls;
if (text) e.textContent = text;
return e;
}
function open(file) {
var L = cfg.labels;
return new Promise(function (resolve) {
var src = null; // Image o canvas sorgente a piena risoluzione
var rect = null; // {x,y,w,h} in px immagine
var pdfDoc = null;
var closed = false;
// ---- DOM (stile Maker: variabili CSS del tema + classi btn gia' presenti) ----
var overlay = el('div', 'fixed inset-0 z-[100] flex items-center justify-center p-3 bg-black/60');
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
var box = el('div', 'rounded-xl shadow-xl flex flex-col w-full max-w-4xl max-h-full');
box.style.cssText = 'background:var(--bg-primary,#fff);color:var(--text-primary,#111);border:1px solid var(--border-color,#ddd);';
var head = el('div', 'flex items-center justify-between gap-3 px-4 py-3');
head.appendChild(el('h3', 'font-semibold text-sm', L.title));
var pageWrap = el('label', 'text-xs items-center gap-2');
pageWrap.style.display = 'none';
pageWrap.appendChild(document.createTextNode(L.page + ' '));
var pageSel = el('select', 'tmf-input text-xs');
pageSel.style.width = 'auto';
pageWrap.appendChild(pageSel);
head.appendChild(pageWrap);
var body = el('div', 'px-4 flex-1 min-h-0 overflow-auto flex justify-center');
var status = el('p', 'text-xs py-6', L.loading);
body.appendChild(status);
var canvas = el('canvas');
canvas.style.cssText = 'max-width:100%;max-height:65vh;touch-action:none;cursor:crosshair;display:none;';
body.appendChild(canvas);
var foot = el('div', 'px-4 py-3 flex flex-wrap items-center justify-between gap-2');
var note = el('p', 'text-xs');
note.style.color = 'var(--text-muted,#666)';
note.textContent = L.hint + (isPdf(file) ? ' ' + L.pdfNote : '');
var btns = el('div', 'flex gap-2');
var bCancel = el('button', 'btn btn-secondary text-xs', L.cancel);
var bOrig = el('button', 'btn btn-secondary text-xs', L.original);
var bOk = el('button', 'btn btn-primary text-xs', L.confirm);
[bCancel, bOrig, bOk].forEach(function (b) { b.type = 'button'; btns.appendChild(b); });
bOk.disabled = true;
foot.appendChild(note);
foot.appendChild(btns);
box.appendChild(head); box.appendChild(body); box.appendChild(foot);
overlay.appendChild(box);
document.body.appendChild(overlay);
function onKey(e) { if (e.key === 'Escape') finish(null); }
function finish(value) {
if (closed) return;
closed = true;
document.removeEventListener('keydown', onKey);
overlay.remove();
resolve(value);
}
document.addEventListener('keydown', onKey);
bCancel.onclick = function () { finish(null); };
bOrig.onclick = function () { finish(file); };
bOk.onclick = function () {
if (!src || !rect) return;
var out = document.createElement('canvas');
out.width = Math.round(rect.w);
out.height = Math.round(rect.h);
out.getContext('2d').drawImage(src, Math.round(rect.x), Math.round(rect.y),
out.width, out.height, 0, 0, out.width, out.height);
out.toBlob(function (blob) { finish(blob || file); }, 'image/png');
};
// ---- disegno ----
function sw() { return src.width || src.naturalWidth; }
function sh() { return src.height || src.naturalHeight; }
function handlePoints(r) {
var mx = r.x + r.w / 2, my = r.y + r.h / 2, ex = r.x + r.w, ey = r.y + r.h;
return [
{ n: 'nw', x: r.x, y: r.y }, { n: 'n', x: mx, y: r.y }, { n: 'ne', x: ex, y: r.y },
{ n: 'e', x: ex, y: my }, { n: 'se', x: ex, y: ey }, { n: 's', x: mx, y: ey },
{ n: 'sw', x: r.x, y: ey }, { n: 'w', x: r.x, y: my }
];
}
function draw() {
var ctx = canvas.getContext('2d');
ctx.drawImage(src, 0, 0, canvas.width, canvas.height);
var r = rect; // il canvas di visualizzazione ha la risoluzione della sorgente
// velo scuro fuori dal ritaglio (4 fasce)
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, 0, canvas.width, r.y);
ctx.fillRect(0, r.y + r.h, canvas.width, canvas.height - r.y - r.h);
ctx.fillRect(0, r.y, r.x, r.h);
ctx.fillRect(r.x + r.w, r.y, canvas.width - r.x - r.w, r.h);
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = Math.max(2, canvas.width / 400);
ctx.strokeRect(r.x, r.y, r.w, r.h);
ctx.fillStyle = '#3b82f6';
var hs = Math.max(8, canvas.width / 80);
handlePoints(r).forEach(function (p) { ctx.fillRect(p.x - hs / 2, p.y - hs / 2, hs, hs); });
}
function setSource(s) {
src = s;
canvas.width = sw();
canvas.height = sh();
rect = { x: 0, y: 0, w: sw(), h: sh() }; // all'inizio: tutta l'immagine
status.style.display = 'none';
canvas.style.display = 'block';
bOk.disabled = false;
draw();
}
// ---- interazione ----
var drag = null;
function toImg(e) {
var b = canvas.getBoundingClientRect();
return { x: (e.clientX - b.left) * sw() / b.width, y: (e.clientY - b.top) * sh() / b.height,
k: sw() / b.width }; // k = px immagine per px schermo
}
function hit(p) {
var best = null, bd = HANDLE * p.k;
handlePoints(rect).forEach(function (h) {
var d = Math.max(Math.abs(h.x - p.x), Math.abs(h.y - p.y));
if (d <= bd) { bd = d; best = h.n; }
});
if (best) return best;
if (p.x > rect.x && p.x < rect.x + rect.w && p.y > rect.y && p.y < rect.y + rect.h) return 'move';
return 'new';
}
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
canvas.addEventListener('pointerdown', function (e) {
if (!src) return;
var p = toImg(e);
var mode = hit(p);
drag = { mode: mode, sx: p.x, sy: p.y, r0: { x: rect.x, y: rect.y, w: rect.w, h: rect.h } };
if (mode === 'new') { // nuovo rettangolo: parte dal punto di tocco
drag.r0 = { x: clamp(p.x, 0, sw()), y: clamp(p.y, 0, sh()), w: 0, h: 0 };
}
canvas.setPointerCapture(e.pointerId);
e.preventDefault();
});
canvas.addEventListener('pointermove', function (e) {
if (!src) return;
var p = toImg(e);
if (!drag) {
var m = hit(p);
canvas.style.cursor = m === 'move' ? 'move' : m === 'new' ? 'crosshair'
: (m.length === 2 ? (m === 'nw' || m === 'se' ? 'nwse-resize' : 'nesw-resize')
: (m === 'n' || m === 's' ? 'ns-resize' : 'ew-resize'));
return;
}
var dx = p.x - drag.sx, dy = p.y - drag.sy, r0 = drag.r0, W = sw(), H = sh();
if (drag.mode === 'move') {
rect.x = clamp(r0.x + dx, 0, W - r0.w);
rect.y = clamp(r0.y + dy, 0, H - r0.h);
} else {
var x1 = r0.x, y1 = r0.y, x2 = r0.x + r0.w, y2 = r0.y + r0.h, m2 = drag.mode;
if (m2 === 'new') { x2 = clamp(p.x, 0, W); y2 = clamp(p.y, 0, H); }
else {
if (m2.indexOf('w') >= 0) x1 = clamp(r0.x + dx, 0, x2 - MIN_SIDE);
if (m2.indexOf('e') >= 0) x2 = clamp(x2 + dx, x1 + MIN_SIDE, W);
if (m2.indexOf('n') >= 0) y1 = clamp(r0.y + dy, 0, y2 - MIN_SIDE);
if (m2.indexOf('s') >= 0) y2 = clamp(y2 + dy, y1 + MIN_SIDE, H);
}
rect = { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
}
bOk.disabled = rect.w < MIN_SIDE || rect.h < MIN_SIDE; // ritaglio troppo piccolo: non confermabile
draw();
e.preventDefault();
});
function up() { drag = null; }
canvas.addEventListener('pointerup', up);
canvas.addEventListener('pointercancel', up);
// ---- caricamento sorgente ----
function fail(err) {
console.error('ImageCrop:', err);
status.style.display = '';
status.textContent = L.error;
canvas.style.display = 'none';
bOk.disabled = true;
}
if (isPdf(file)) {
loadPdfJs().then(function (lib) {
if (cfg.workerSrc) lib.GlobalWorkerOptions.workerSrc = cfg.workerSrc;
return file.arrayBuffer().then(function (buf) { return lib.getDocument({ data: buf }).promise; });
}).then(function (pdf) {
pdfDoc = pdf;
if (pdf.numPages > 1) {
for (var i = 1; i <= pdf.numPages; i++) {
var o = el('option', '', String(i)); o.value = String(i); pageSel.appendChild(o);
}
pageWrap.style.display = 'flex';
}
return renderPdfPage(pdf, 1);
}).then(setSource).catch(fail);
pageSel.onchange = function () {
bOk.disabled = true;
renderPdfPage(pdfDoc, parseInt(pageSel.value, 10)).then(setSource).catch(fail);
};
} else {
readImage(file).then(setSource).catch(fail);
}
});
}
global.ImageCrop = { open: open, configure: configure };
})(window);
@@ -0,0 +1,60 @@
/* Login Operatore: nome + PIN a 4 cifre (#59).
* Componente Alpine globale `pinLogin()`, usato da templates/auth/login.html.
* Contratto: GET /auth/pin-users -> [{id, display_name}];
* POST /auth/login-pin {user_id, pin} -> {ok, redirect} | {error, detail}. */
function pinLogin() {
return {
mode: 'pin', // vista Operatore di default; se l'elenco e' vuoto/fallisce passa a 'password'
users: [],
selected: null,
pin: '',
error: '',
busy: false,
init: function () {
var self = this;
fetch(window.TMF_PIN.usersUrl, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
self.users = Array.isArray(list) ? list : [];
if (!self.users.length) self.mode = 'password'; // elenco vuoto: form password
})
.catch(function () { self.users = []; self.mode = 'password'; });
},
select: function (u) { this.selected = u; this.pin = ''; this.error = ''; },
back: function () { this.selected = null; this.pin = ''; this.error = ''; },
del: function () { this.pin = this.pin.slice(0, -1); },
press: function (d) {
if (this.busy || this.pin.length >= 4) return;
this.pin += d;
this.error = '';
if (this.pin.length === 4) this.submit(); // invio automatico alla quarta cifra
},
submit: function () {
var self = this;
var csrf = document.querySelector('meta[name=csrf-token]');
self.busy = true;
fetch(window.TMF_PIN.loginUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRFToken': (csrf && csrf.content) || ''
},
body: JSON.stringify({ user_id: self.selected.id, pin: self.pin })
})
.then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (d) {
if (d.ok && d.redirect) { window.location.href = d.redirect; return; }
self.error = d.detail || window.TMF_PIN.msgNet; // 401 PIN errato, 423 blocco con ora
self.pin = '';
self.busy = false;
})
.catch(function () {
self.error = window.TMF_PIN.msgNet;
self.pin = '';
self.busy = false;
});
}
};
}
@@ -0,0 +1,272 @@
/**
* Task results (#53 #54 #55) - Alpine component for the tasks that record a result.
*
* - «check» task (#54): the operator answers OK or NOK, with a note (mandatory for NOK).
* - «note» task with `fields` (#55): one input per field, an optional attachment
* (image / PDF) for the fields that ask for it, outcome 'done'.
*
* The same component serves the task being executed (one form) and the panel shown
* at the end of production (one form per at_close task), so the field rules live here
* once. The page sets `window.__taskResultsCfg` (urls + labels) and the component's
* `ctx` (production run id + callbacks) from its x-init, the way visionExecute() does.
*
* Plain functions only (no framework imports) so tests/js/test_task_results.js can
* run them under node with a stubbed fetch.
*/
function taskResultForm() {
return {
// Set by the page's x-init: () => ({ runId: int|null, done: () => void })
ctx: null,
forms: [],
working: false,
error: '',
// true once a result was recorded during this page view (shows «Prosegui»)
justRecorded: false,
// Panel open state (used by the end-of-production panel only)
open: false,
cfg() { return window.__taskResultsCfg || { labels: {} }; },
label(key, fallback) {
return (this.cfg().labels && this.cfg().labels[key]) || fallback;
},
runId() {
const c = this.ctx ? this.ctx() : null;
return c && c.runId ? c.runId : null;
},
/* One form per task: values start from the field's default, the way the maker set it. */
makeForm(task) {
const fields = Array.isArray(task.fields) ? task.fields : [];
const values = {};
fields.forEach((f) => { values[f.key] = (f.default === null || f.default === undefined) ? '' : String(f.default); });
return {
task_id: task.id,
title: task.title || '',
directive: task.directive || '',
type: task.task_type,
fields: fields,
values: values,
files: {},
fileNames: {},
note: '',
outcome: '',
error: '',
result: null,
recorded: false,
};
},
/* The single form of the task on screen. */
initForTask(task) {
if (!task) return;
const isCheck = task.task_type === 'check';
const isNoteWithFields = task.task_type === 'note' && Array.isArray(task.fields) && task.fields.length > 0;
if (!isCheck && !isNoteWithFields) return;
this.forms = [this.makeForm(task)];
this.loadLast();
},
pickFile(form, key, ev) {
const f = ev && ev.target && ev.target.files ? ev.target.files[0] : null;
if (f) { form.files[key] = f; form.fileNames[key] = f.name; }
else { delete form.files[key]; form.fileNames[key] = ''; }
},
csrf() {
const m = typeof document !== 'undefined' ? document.querySelector('meta[name=csrf-token]') : null;
return (m && m.content) || '';
},
/* Fetch + JSON with the page's usual error reading: the detail of the server is
what the operator sees; an HTML answer (expired CSRF / session) means «reload». */
async call(url, opts) {
let resp;
try {
resp = await fetch(url, opts);
} catch (e) {
return { ok: false, detail: this.label('connection', 'Errore di connessione') };
}
const data = await resp.json().catch(() => null);
if (!data || data.csrf_expired || data.session_expired) {
return { ok: false, detail: this.label('sessionExpired', 'Sessione scaduta: ricaricare la pagina') };
}
if (!resp.ok) {
return { ok: false, detail: data.detail || this.label('serverError', 'Errore di comunicazione con il server') };
}
return { ok: true, data: data };
},
/* The last result of this task: in the run under way if there is one, otherwise
the one given in this browser session (no run = nothing on the server to ask).
Only shown: the buttons stay active, a loop gives a new result every cycle. */
async loadLast() {
const form = this.forms[0];
if (!form) return;
const rid = this.runId();
if (rid && this.cfg().listUrl) {
const r = await this.call(
this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid) +
'&task_id=' + encodeURIComponent(form.task_id));
if (r.ok && Array.isArray(r.data) && r.data.length) {
form.result = r.data[r.data.length - 1];
return;
}
}
try {
const raw = window.sessionStorage.getItem('tmf-task-result-' + form.task_id);
if (raw && !rid) form.result = JSON.parse(raw);
} catch (e) { /* storage may be unavailable: the result is simply not shown */ }
},
/* Validation before anything goes on the wire. Returns the message or ''. */
validate(form, outcome) {
if (form.type === 'check') {
if (outcome === 'nok' && !(form.note || '').trim()) {
return this.label('nokNeedsNote', 'Per un NOK serve la nota');
}
return '';
}
for (const f of form.fields) {
const v = form.values[f.key];
if (f.type === 'number' && v !== '' && v !== null && v !== undefined &&
!isFinite(Number(String(v).replace(',', '.')))) {
return this.label('notANumber', 'Valore non numerico') + ': ' + f.label;
}
}
return '';
},
/* Upload the attachments of a form, then POST the result.
Returns true when the result is recorded (forms already recorded are skipped). */
async record(form, outcome) {
if (form.recorded) return true;
form.error = this.validate(form, outcome);
if (form.error) return false;
const attachments = [];
for (const f of form.fields) {
const file = form.files[f.key];
if (!f.attachment || !file) continue;
const fd = new FormData();
fd.append('file', file, file.name);
const up = await this.call(this.cfg().uploadUrl, {
method: 'POST', headers: { 'X-CSRFToken': this.csrf() }, body: fd,
});
if (!up.ok) { form.error = up.detail; return false; }
// The backend answers {file_path, ...}: that path is what the result keeps.
attachments.push({ key: f.key, path: up.data.file_path || up.data.path, name: file.name });
}
let values = null;
if (form.type === 'note' && form.fields.length) {
values = {};
form.fields.forEach((f) => {
const v = form.values[f.key];
if (v === '' || v === null || v === undefined) return;
values[f.key] = f.type === 'number' ? String(v).replace(',', '.') : String(v);
});
}
const body = {
task_id: form.task_id,
production_run_id: this.runId(),
outcome: form.type === 'check' ? outcome : 'done',
note: (form.note || '').trim() || null,
values: values,
attachments: attachments.length ? attachments : null,
};
const r = await this.call(this.cfg().saveUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrf() },
body: JSON.stringify(body),
});
if (!r.ok) { form.error = r.detail; return false; }
form.result = r.data;
form.recorded = true;
try {
window.sessionStorage.setItem('tmf-task-result-' + form.task_id, JSON.stringify(r.data));
} catch (e) { /* storage may be unavailable */ }
return true;
},
/* Task on screen: OK / NOK button, or «Completato» of a note with fields. */
async submit(outcome) {
const form = this.forms[0];
if (!form || this.working) return;
this.working = true;
this.error = '';
const ok = await this.record(form, outcome);
this.working = false;
if (!ok) return;
this.justRecorded = true;
this.proceed();
},
/* Hand over to the page: close the cycle / go on. Also bound to «Prosegui», for
when that step failed after the result was already recorded. */
proceed() {
const c = this.ctx ? this.ctx() : null;
if (c && c.done) c.done();
},
/* The result as the operator reads it. */
resultLabel(result) {
if (!result) return '';
if (result.outcome === 'ok') return this.label('outcomeOk', 'OK');
if (result.outcome === 'nok') return this.label('outcomeNok', 'NOK');
return this.label('outcomeDone', 'Registrato');
},
// ---- End of production panel (#55) ----
/* Open the panel for the at_close tasks of the run. A task that already has a
result in this run (page reloaded in between) is not asked again. */
async openClose(ids) {
this.error = '';
this.working = true;
this.open = true;
this.forms = [];
const rid = this.runId();
let done = [];
if (rid && this.cfg().listUrl) {
const r = await this.call(this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid));
if (r.ok && Array.isArray(r.data)) done = r.data.map((x) => x.task_id);
}
const forms = [];
for (const id of ids) {
const r = await this.call(this.cfg().taskUrlTpl.replace('/0', '/' + id));
if (!r.ok) { this.error = r.detail; this.working = false; return; }
const form = this.makeForm(r.data);
if (done.indexOf(id) !== -1) form.recorded = true;
forms.push(form);
}
this.forms = forms;
this.working = false;
// Nothing left to ask: straight on to the capoturno.
if (forms.every((f) => f.recorded)) this.finishClose();
},
cancelClose() { this.open = false; this.forms = []; this.error = ''; },
async submitClose() {
if (this.working) return;
this.working = true;
this.error = '';
let allOk = true;
for (const form of this.forms) {
// Keep going after a failure: each form shows its own error, and the ones
// already recorded are not sent again on the next try.
if (!(await this.record(form, 'done'))) allOk = false;
}
this.working = false;
if (allOk) this.finishClose();
},
finishClose() {
this.open = false;
this.proceed();
},
};
}
if (typeof module !== 'undefined') module.exports = { taskResultForm };
@@ -0,0 +1,125 @@
/**
* Vision execute - Alpine component for camera_measure / dxf_compare tasks (#46).
*
* The operator takes (or picks) an image, `acquire(blob)` sends it to the Flask
* proxy, and the result - quote table, outcome, failures, overlay - is shown on
* the image. `acquire(blob)` is the single entry point on purpose: the agent
* camera (#3) will call it with its own blob and reuse everything below.
*
* Config comes from window.__visionCfg (set by the template):
* executeUrl, imageUrlTpl (with a 0 placeholder for the result id), taskId,
* taskType, subtasks, labels.
* Run context (lot, serial, production run) is read lazily through `ctx()`,
* which the template binds to the parent task component.
*/
function visionExecute() {
// Overlay colour per role (VisionSuite roles: reference, roi, found).
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
return {
busy: false,
error: '',
result: null, // last execute response
imageSrc: '', // object URL of the acquired image (shown under the overlay)
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
// Entry point shared with the future agent camera.
async acquire(blob) {
if (!blob || this.busy) return;
const cfg = window.__visionCfg || {};
this.busy = true;
this.error = '';
this.result = null;
// Show the picked image at once: the overlay is drawn on top when the result arrives.
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageSrc = URL.createObjectURL(blob);
try {
const c = this.ctx() || {};
const fd = new FormData();
fd.append('task_id', cfg.taskId);
fd.append('task_type', cfg.taskType || '');
fd.append('image', blob, blob.name || 'capture.jpg');
fd.append('lot_number', c.lot || '');
fd.append('serial_number', c.serial || '');
if (c.runId) fd.append('production_run_id', c.runId);
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch(cfg.executeUrl, {
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok || body.error) {
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
return;
}
this.result = body;
// Draw once Alpine has rendered the canvas.
this.$nextTick(() => this.drawOverlay());
} catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
} finally {
this.busy = false;
}
},
// Input change: take the file and allow picking the same one again.
onPick(ev) {
const f = ev.target.files && ev.target.files[0];
ev.target.value = '';
if (f) this.acquire(f);
},
// Image shown: the saved one from the server when available, else the local one.
get shownImage() {
const cfg = window.__visionCfg || {};
if (this.result && this.result.vision_result_id && cfg.imageUrlTpl) {
return cfg.imageUrlTpl.replace('/0/', '/' + this.result.vision_result_id + '/');
}
return this.imageSrc;
},
// Quote rows: server measurements joined with the subtask names.
get rows() {
const cfg = window.__visionCfg || {};
const subs = cfg.subtasks || [];
return ((this.result && this.result.measurements) || []).map((m) => {
const st = subs.find((s) => s.id === m.subtask_id) || {};
return {
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
value: m.value,
unit: st.unit || 'mm',
status: m.pass_fail,
};
});
},
statusClass(s) {
// Same palette as the measurement markers of the manual flow.
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
if (s === 'fail') return 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40';
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
},
// Canvas is sized to the natural image, so overlay coordinates (image px)
// are used as they are and CSS scales the whole thing to the displayed size.
drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
g.lineJoin = 'round';
for (const item of ((this.result && this.result.overlay) || [])) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
},
};
}
@@ -0,0 +1,80 @@
// Ricetta visione del Maker (#46): componente Alpine per i task camera_measure / dxf_compare.
// Carica la ricetta VisionSuite v2 + i file allegati verso /maker/api/vision/tasks/<id>/recipe,
// mostra cartella, file e uscite numeriche; notifica le uscite al task editor con un evento.
// Uso: x-data="visionRecipe(task.id, csrfToken, msgs)". Dipende da Alpine.js.
function visionRecipe(taskId, csrf, msgs) {
return {
taskId: taskId,
recipeDir: null,
files: [],
outputs: [],
errors: [], // errori 422 elencati in chiaro
loading: false,
busy: false,
recipeFile: null,
attachments: [],
msgs: msgs || {},
async init() {
// All'apertura: ricetta corrente (GET); il 404/errore non blocca l'editor
this.loading = true;
try {
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`);
if (resp.ok) this._apply(await resp.json());
} catch (e) { console.error('visionRecipe init', e); }
this.loading = false;
},
// Aggiorna lo stato e avvisa il task editor (select vision_output delle quote)
_apply(data) {
this.recipeDir = data.recipe_dir || null;
this.files = data.files || [];
this.outputs = data.outputs || [];
window.dispatchEvent(new CustomEvent('vision-outputs', {
detail: { taskId: this.taskId, outputs: this.outputs }
}));
},
// Il 422 porta `errors` (lista) oppure `detail` (stringa o lista di voci)
_errorsFrom(data) {
if (Array.isArray(data.errors) && data.errors.length) return data.errors.map(String);
const d = data.detail;
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x))));
if (d && typeof d === 'object') {
if (Array.isArray(d.errors)) return d.errors.map(String);
return [JSON.stringify(d)];
}
return [d || this.msgs.genericError || 'Error'];
},
async upload() {
if (!this.recipeFile) return;
this.busy = true;
this.errors = [];
try {
const fd = new FormData();
fd.append('recipe', this.recipeFile);
for (const f of this.attachments) fd.append('files', f);
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`, {
method: 'POST',
headers: { 'X-CSRFToken': typeof csrf === 'function' ? csrf() : csrf },
body: fd
});
const data = await resp.json();
if (!resp.ok || data.error) {
this.errors = this._errorsFrom(data);
} else {
this._apply(data);
this.recipeFile = null;
this.attachments = [];
}
} catch (e) {
this.errors = [this.msgs.genericError || String(e)];
}
this.busy = false;
},
onRecipe(ev) { this.recipeFile = ev.target.files[0] || null; },
onAttachments(ev) { this.attachments = Array.from(ev.target.files || []); }
};
}
@@ -1,18 +1,7 @@
{% extends "base.html" %}
{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %}
{% block content %}
{# Pannello utenti: incluso nella tab «Utenti» di Impostazioni (#50) #}
<script>window.__users = {{ users|tojson }};</script>
<div class="tmf-page"
x-data="userManagement(window.__users)">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Utenti') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci gli utenti del sistema') }}</p>
</div>
<div x-data="userManagement(window.__users)">
<!-- Toolbar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
@@ -66,6 +55,10 @@
<template x-if="user.is_admin">
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">Admin</span>
</template>
<!-- #59: indicatore PIN impostato (solo se il backend manda has_pin) -->
<template x-if="!user.is_admin && user.has_pin === true">
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300">{{ _('PIN impostato') }}</span>
</template>
</div>
</div>
</td>
@@ -102,6 +95,16 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<!-- #51: reset password dall'admin -->
<button @click="openReset(user)" class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="{{ _('Reset password')|tojson_attr }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</button>
<!-- #59: imposta/reset PIN (non per gli admin: il backend risponde 422) -->
<template x-if="!user.is_admin">
<button @click="openPin(user)" class="px-1.5 py-1 rounded-lg text-[11px] font-semibold text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="user.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}">PIN</button>
</template>
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
:class="user.active
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
@@ -215,6 +218,20 @@
minlength="6">
</div>
<!-- #59: PIN facoltativo, solo in creazione di un non admin -->
<template x-if="!isEditing && !form.is_admin">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">
{{ _('PIN') }}
<span class="font-normal text-[var(--text-secondary)]"> ({{ _('facoltativo, 4 cifre') }})</span>
</label>
<input type="text" x-model="form.pin" inputmode="numeric" maxlength="4" autocomplete="off"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="1234">
</div>
</template>
<!-- Roles -->
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
@@ -332,210 +349,106 @@
</div>
</div>
<!-- #51: Modal reset password (la password attuale non si mostra mai) -->
<div x-show="showReset" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeReset()">
<div class="absolute inset-0 bg-black/50" @click="closeReset()"></div>
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
{{ _('Reset password') }}: <span x-text="resetUser?.username"></span>
</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Le sessioni aperte dell\'utente verranno chiuse e dovrà accedere con la nuova password.') }}
</p>
<template x-if="!resetDone">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuova password (minimo 8 caratteri)') }}</label>
<div class="flex gap-2">
<input type="text" x-model="resetPw" autocomplete="off"
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
<button @click="generateResetPw()" type="button"
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
{{ _('Genera') }}
</button>
</div>
<template x-if="resetError">
<p class="mt-2 text-sm text-red-600" x-text="resetError"></p>
</template>
</div>
</template>
<template x-if="resetDone">
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
<p class="text-sm text-emerald-700 dark:text-emerald-300">{{ _('Password cambiata. Annotala ora: non verrà mostrata di nuovo.') }}</p>
<p class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="resetPw"></p>
</div>
</template>
<div class="flex justify-end gap-3 mt-5">
<button @click="closeReset()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
<span x-text="resetDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
</button>
<button x-show="!resetDone" @click="confirmReset()" :disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
{{ _('Imposta password') }}
</button>
</div>
</div>
</div>
<!-- #59: Modal imposta / reset / togli PIN (il PIN attuale non si mostra mai) -->
<div x-show="showPin" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closePin()">
<div class="absolute inset-0 bg-black/50" @click="closePin()"></div>
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
<span x-text="pinUser?.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}"></span>: <span x-text="pinUser?.username"></span>
</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Il PIN serve per accedere alla postazione e, per il capoturno, per autorizzare le azioni.') }}
</p>
<template x-if="!pinDone">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuovo PIN (4 cifre)') }}</label>
<div class="flex gap-2">
<input type="text" x-model="pinValue" inputmode="numeric" maxlength="4" autocomplete="off"
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
<button @click="generatePin()" type="button"
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
{{ _('Genera') }}
</button>
</div>
<template x-if="pinError">
<p class="mt-2 text-sm text-red-600" x-text="pinError"></p>
</template>
</div>
</template>
<template x-if="pinDone">
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
<p class="text-sm text-emerald-700 dark:text-emerald-300" x-text="pinRemoved ? {{ _('PIN tolto.')|tojson_attr }} : {{ _('PIN impostato. Annotalo ora: non verrà mostrato di nuovo.')|tojson_attr }}"></p>
<p x-show="!pinRemoved" class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="pinValue"></p>
</div>
</template>
<div class="flex items-center justify-between gap-3 mt-5">
<div>
<button x-show="!pinDone && pinUser?.has_pin" @click="removePin()" :disabled="saving"
class="px-4 py-2 text-sm font-medium text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50">
{{ _('Togli PIN') }}
</button>
</div>
<div class="flex gap-3">
<button @click="closePin()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
<span x-text="pinDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
</button>
<button x-show="!pinDone" @click="confirmPin()" :disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
{{ _('Imposta PIN') }}
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function userManagement(initialUsers) {
return {
users: initialUsers || [],
search: '',
showModal: false,
showToggleConfirm: false,
isEditing: false,
editingUserId: null,
toggleUser: null,
saving: false,
errorMsg: '',
form: {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
},
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
return meta ? meta.content : '';
},
get filteredUsers() {
if (!this.search.trim()) return this.users;
const q = this.search.toLowerCase();
return this.users.filter(u =>
u.username.toLowerCase().includes(q) ||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
(u.email && u.email.toLowerCase().includes(q)) ||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
);
},
openCreateModal() {
this.isEditing = false;
this.editingUserId = null;
this.errorMsg = '';
this.form = {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
};
this.showModal = true;
},
openEditModal(user) {
this.isEditing = true;
this.editingUserId = user.id;
this.errorMsg = '';
this.form = {
username: user.username,
display_name: user.display_name || '',
email: user.email || '',
password: '',
roles: [...(user.roles || [])],
is_admin: user.is_admin || false,
language_pref: user.language_pref || 'it',
theme_pref: user.theme_pref || 'light'
};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errorMsg = '';
},
async saveUser() {
this.saving = true;
this.errorMsg = '';
try {
if (this.isEditing) {
// Update user
const data = {
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nel salvataggio';
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.editingUserId);
if (idx >= 0) this.users[idx] = result;
// Change password if provided
if (this.form.password) {
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ password: this.form.password })
});
if (!pwResp.ok) {
const pwResult = await pwResp.json();
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
return;
}
}
} else {
// Create user
if (!this.form.username || !this.form.display_name || !this.form.password) {
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
return;
}
const data = {
username: this.form.username,
password: this.form.password,
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch('/admin/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nella creazione';
return;
}
this.users.push(result);
}
this.closeModal();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
toggleActive(user) {
this.toggleUser = user;
this.showToggleConfirm = true;
},
async confirmToggle() {
if (!this.toggleUser) return;
this.saving = true;
try {
const newActive = !this.toggleUser.active;
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ active: newActive })
});
const result = await resp.json();
if (!resp.ok) {
alert(result.detail || 'Errore');
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
if (idx >= 0) this.users[idx] = result;
this.showToggleConfirm = false;
this.toggleUser = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
}
};
}
</script>
{% endblock %}
@@ -0,0 +1,355 @@
<script>
function userManagement(initialUsers) {
return {
users: initialUsers || [],
search: '',
showModal: false,
showToggleConfirm: false,
// #51: stato del reset password
showReset: false,
resetUser: null,
resetPw: '',
resetDone: false,
resetError: '',
// #59: stato del modal PIN
showPin: false,
pinUser: null,
pinValue: '',
pinDone: false,
pinRemoved: false,
pinError: '',
isEditing: false,
editingUserId: null,
toggleUser: null,
saving: false,
errorMsg: '',
form: {
username: '',
display_name: '',
email: '',
password: '',
pin: '', // #59: PIN facoltativo alla creazione
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
},
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
return meta ? meta.content : '';
},
get filteredUsers() {
if (!this.search.trim()) return this.users;
const q = this.search.toLowerCase();
return this.users.filter(u =>
u.username.toLowerCase().includes(q) ||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
(u.email && u.email.toLowerCase().includes(q)) ||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
);
},
openCreateModal() {
this.isEditing = false;
this.editingUserId = null;
this.errorMsg = '';
this.form = {
username: '',
display_name: '',
email: '',
password: '',
pin: '', // #59
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
};
this.showModal = true;
},
openEditModal(user) {
this.isEditing = true;
this.editingUserId = user.id;
this.errorMsg = '';
this.form = {
username: user.username,
display_name: user.display_name || '',
email: user.email || '',
password: '',
roles: [...(user.roles || [])],
is_admin: user.is_admin || false,
language_pref: user.language_pref || 'it',
theme_pref: user.theme_pref || 'light'
};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errorMsg = '';
},
async saveUser() {
this.saving = true;
this.errorMsg = '';
try {
if (this.isEditing) {
// Update user
const data = {
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nel salvataggio';
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.editingUserId);
if (idx >= 0) this.users[idx] = result;
// Change password if provided
if (this.form.password) {
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ password: this.form.password })
});
if (!pwResp.ok) {
const pwResult = await pwResp.json();
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
return;
}
}
} else {
// Create user
if (!this.form.username || !this.form.display_name || !this.form.password) {
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
return;
}
// #59: PIN facoltativo, 4 cifre, solo per i non admin
const pin = this.form.is_admin ? '' : (this.form.pin || '').trim();
if (pin && !/^\d{4}$/.test(pin)) {
this.errorMsg = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
return;
}
const data = {
username: this.form.username,
password: this.form.password,
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch('/admin/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nella creazione';
return;
}
this.users.push(result);
// #59: l'utente esiste gia', il PIN si imposta con la chiamata dedicata
if (pin) {
const pinResp = await fetch(`/admin/api/users/${result.id}/pin`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ pin: pin })
});
if (!pinResp.ok) {
const pinResult = await pinResp.json().catch(() => ({}));
// resta aperto in modifica sull'utente appena creato, cosi' non si ricrea
this.isEditing = true;
this.editingUserId = result.id;
this.errorMsg = {{ _('Utente creato, ma PIN non impostato')|tojson }} + ': ' + (pinResult.detail || '');
return;
}
result.has_pin = true;
}
}
this.closeModal();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
// #51: reset password dall'admin, usa PUT /admin/api/users/<id>/password
openReset(user) {
this.resetUser = user;
this.resetPw = '';
this.resetDone = false;
this.resetError = '';
this.showReset = true;
},
closeReset() {
this.showReset = false;
this.resetPw = ''; // la password generata non resta in memoria
this.resetDone = false;
},
generateResetPw() {
// 12 caratteri senza simboli ambigui (0/O, 1/l/I), da crypto random
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
const buf = new Uint32Array(12);
crypto.getRandomValues(buf);
this.resetPw = Array.from(buf, n => chars[n % chars.length]).join('');
this.resetError = '';
},
async confirmReset() {
if (!this.resetPw || this.resetPw.length < 8) {
this.resetError = {{ _('La password deve avere almeno 8 caratteri')|tojson }};
return;
}
this.saving = true;
this.resetError = '';
try {
const resp = await fetch(`/admin/api/users/${this.resetUser.id}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ password: this.resetPw })
});
const result = await resp.json();
if (!resp.ok) {
this.resetError = result.detail || {{ _('Errore nel cambio password')|tojson }};
return;
}
this.resetDone = true;
} catch (e) {
this.resetError = {{ _('Errore di connessione al server')|tojson }};
} finally {
this.saving = false;
}
},
// #59: imposta / reset / togli PIN, usa PUT /admin/api/users/<id>/pin
openPin(user) {
this.pinUser = user;
this.pinValue = '';
this.pinDone = false;
this.pinRemoved = false;
this.pinError = '';
this.showPin = true;
},
closePin() {
this.showPin = false;
this.pinValue = ''; // il PIN generato non resta in memoria
this.pinDone = false;
},
generatePin() {
// 4 cifre da crypto random
const buf = new Uint32Array(4);
crypto.getRandomValues(buf);
this.pinValue = Array.from(buf, n => n % 10).join('');
this.pinError = '';
},
async savePin(pin) {
// pin = stringa di 4 cifre oppure null (toglie il PIN)
this.saving = true;
this.pinError = '';
try {
const resp = await fetch(`/admin/api/users/${this.pinUser.id}/pin`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ pin: pin })
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok) {
this.pinError = result.detail || {{ _('Errore nel salvataggio del PIN')|tojson }};
return;
}
// aggiorna l'indicatore locale
const idx = this.users.findIndex(u => u.id === this.pinUser.id);
if (idx >= 0) this.users[idx] = Object.assign({}, this.users[idx], { has_pin: pin !== null });
this.pinUser = idx >= 0 ? this.users[idx] : this.pinUser;
this.pinRemoved = pin === null;
this.pinDone = true;
} catch (e) {
this.pinError = {{ _('Errore di connessione al server')|tojson }};
} finally {
this.saving = false;
}
},
async confirmPin() {
if (!/^\d{4}$/.test(this.pinValue)) {
this.pinError = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
return;
}
await this.savePin(this.pinValue);
},
async removePin() {
await this.savePin(null);
},
toggleActive(user) {
this.toggleUser = user;
this.showToggleConfirm = true;
},
async confirmToggle() {
if (!this.toggleUser) return;
this.saving = true;
try {
const newActive = !this.toggleUser.active;
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ active: newActive })
});
const result = await resp.json();
if (!resp.ok) {
alert(result.detail || 'Errore');
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
if (idx >= 0) this.users[idx] = result;
this.showToggleConfirm = false;
this.toggleUser = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
}
};
}
</script>
@@ -3,17 +3,32 @@
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
{% block content %}
<div class="tmf-page tmf-page-narrow"
x-data="systemSettings()">
<div class="tmf-page" x-data="{ tab: {{ active_tab|tojson_attr }} }">
<!-- Header -->
<div class="mb-6">
<div class="mb-4">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
</div>
<!-- #50: tab. L'intera pagina e' gia' solo per admin (admin_required) -->
<div class="flex gap-1 mb-6 border-b border-[var(--border-color)]">
<button type="button" @click="tab = 'generale'"
:class="tab === 'generale' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Generale') }}</button>
<button type="button" @click="tab = 'utenti'"
:class="tab === 'utenti' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Utenti') }}</button>
</div>
<div x-show="tab === 'utenti'" x-cloak>
{% include "admin/_users_panel.html" %}
</div>
{# #65: niente larghezza propria, la scheda sta nella cornice .tmf-page #}
<div x-show="tab === 'generale'" x-data="systemSettings()">
<!-- Settings Card -->
<div class="tmf-card">
<div class="tmf-card mb-6">
<div class="tmf-card-header flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
@@ -43,6 +58,57 @@
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
</p>
</div>
<!-- #47: durata loop predefinita delle ricette -->
<div class="pt-5 border-t border-[var(--border-color)]">
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Ricette') }}</h3>
<label class="tmf-label" for="default-interval">{{ _('Durata loop predefinita (minuti)') }}</label>
<input id="default-interval" type="number" x-model="defaultInterval" class="tmf-input w-32" min="1" max="1440" placeholder="—">
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Proposta come intervallo di misura nelle nuove ricette (1-1440). Vuoto = nessun predefinito.') }}</p>
</div>
<!-- #48: tolleranze generali -->
<div class="pt-5 border-t border-[var(--border-color)]">
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Tolleranze generali') }}</h3>
<div class="flex items-center gap-6">
<div>
<label class="tmf-label" for="tol-plus">{{ _('Tolleranza +') }}</label>
<input id="tol-plus" type="text" inputmode="decimal" x-model="tolPlus" class="tmf-input w-32" placeholder="—">
</div>
<div>
<label class="tmf-label" for="tol-minus">{{ _('Tolleranza −') }}</label>
<input id="tol-minus" type="text" inputmode="decimal" x-model="tolMinus" class="tmf-input w-32" placeholder="—">
</div>
</div>
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Valori positivi (zero o più) proposti come tolleranza nelle nuove misure. Vuoto = nessun predefinito.') }}</p>
</div>
<!-- #49: anagrafiche (liste di scelta) -->
<div class="pt-5 border-t border-[var(--border-color)]">
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Anagrafiche') }}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<template x-for="lst in ['types', 'units']" :key="lst">
<div>
<label class="tmf-label" x-text="lst === 'types' ? {{ _('Tipi di misura')|tojson_attr }} : {{ _('Unità di misura')|tojson_attr }}"></label>
<ul class="space-y-1 mb-2">
<template x-for="(item, i) in lists[lst]" :key="lst + i">
<li class="flex items-center gap-1 text-sm">
<span class="flex-1 px-2 py-1 rounded bg-[var(--bg-secondary)] text-[var(--text-primary)]" x-text="item"></span>
<button type="button" @click="moveItem(lst, i, -1)" :disabled="i === 0" class="px-1.5 text-[var(--text-muted)] hover:text-primary disabled:opacity-30" title="{{ _('Su') }}">▲</button>
<button type="button" @click="moveItem(lst, i, 1)" :disabled="i === lists[lst].length - 1" class="px-1.5 text-[var(--text-muted)] hover:text-primary disabled:opacity-30" title="{{ _('Giù') }}">▼</button>
<button type="button" @click="removeItem(lst, i)" class="px-1.5 text-[var(--text-muted)] hover:text-red-500" title="{{ _('Rimuovi') }}">✕</button>
</li>
</template>
</ul>
<div class="flex gap-2">
<input type="text" x-model="newItem[lst]" @keydown.enter.prevent="addItem(lst)" class="tmf-input flex-1" placeholder="{{ _('Nuova voce') }}">
<button type="button" @click="addItem(lst)" class="btn btn-secondary">{{ _('Aggiungi') }}</button>
</div>
</div>
</template>
</div>
<p class="text-xs text-[var(--text-muted)] mt-2">{{ _('Massimo 50 voci per lista. Un elenco vuoto = nessuna scelta predefinita.') }}</p>
</div>
</div>
<div class="tmf-card-footer flex items-center justify-between">
@@ -68,18 +134,48 @@
</button>
</div>
</div>
</div><!-- /tab generale -->
</div>
{% endblock %}
{% block extra_js %}
<script>
// #49: legge un array JSON di stringhe salvato come impostazione; vuoto/malformato = lista vuota
function tmfParseList(raw) {
try { var a = JSON.parse(raw || '[]'); return Array.isArray(a) ? a.map(String) : []; }
catch (e) { return []; }
}
function systemSettings() {
return {
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
// #47-#49: nuove chiavi (valori stringa, vuoto = nessun default)
defaultInterval: {{ (settings.default_measurement_interval_minutes or '')|tojson }},
tolPlus: {{ (settings.general_tolerance_plus or '')|tojson }},
tolMinus: {{ (settings.general_tolerance_minus or '')|tojson }},
lists: {
types: tmfParseList({{ (settings.list_measurement_types or '')|tojson }}),
units: tmfParseList({{ (settings.list_units or '')|tojson }})
},
newItem: { types: '', units: '' },
saving: false,
saved: false,
errorMessage: '',
addItem(lst) {
var v = (this.newItem[lst] || '').trim();
if (!v) return;
if (this.lists[lst].length >= 50) { this.errorMessage = {{ _('Massimo 50 voci per lista')|tojson }}; return; }
if (this.lists[lst].indexOf(v) === -1) this.lists[lst].push(v);
this.newItem[lst] = '';
},
removeItem(lst, i) { this.lists[lst].splice(i, 1); },
moveItem(lst, i, d) {
var a = this.lists[lst].slice(), j = i + d;
if (j < 0 || j >= a.length) return;
var t = a[i]; a[i] = a[j]; a[j] = t;
this.lists[lst] = a;
},
async save() {
this.saving = true;
this.saved = false;
@@ -87,7 +183,13 @@ function systemSettings() {
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
var payload = {
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : ''
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : '',
default_measurement_interval_minutes: String(this.defaultInterval || '').trim(),
// virgola decimale accettata in input, il backend vuole il punto
general_tolerance_plus: String(this.tolPlus || '').trim().replace(',', '.'),
general_tolerance_minus: String(this.tolMinus || '').trim().replace(',', '.'),
list_measurement_types: this.lists.types.length ? JSON.stringify(this.lists.types) : '',
list_units: this.lists.units.length ? JSON.stringify(this.lists.units) : ''
};
try {
@@ -111,4 +213,5 @@ function systemSettings() {
};
}
</script>
{% include "admin/_users_script.html" %}
{% endblock %}
@@ -204,6 +204,17 @@
maxlength="255">
</div>
{# Worker visione (#45): vuoto = worker centrale #}
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Worker visione (URL)') }}</label>
<input type="text" x-model="form.vision_worker_url"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="host:8100"
maxlength="500">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}</p>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
<textarea x-model="form.notes" rows="3"
@@ -452,6 +463,7 @@ function stationManagement(initialStations, initialRecipes) {
name: '',
location: '',
notes: '',
vision_worker_url: '', // #45
active: true,
},
@@ -505,7 +517,7 @@ function stationManagement(initialStations, initialRecipes) {
this.isEditing = false;
this.editingId = null;
this.errorMsg = '';
this.form = { code: '', name: '', location: '', notes: '', active: true };
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', active: true };
this.showModal = true;
},
@@ -518,6 +530,7 @@ function stationManagement(initialStations, initialRecipes) {
name: station.name,
location: station.location || '',
notes: station.notes || '',
vision_worker_url: station.vision_worker_url || '', // #45
active: station.active,
};
this.showModal = true;
@@ -537,6 +550,7 @@ function stationManagement(initialStations, initialRecipes) {
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
active: this.form.active,
};
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
@@ -563,6 +577,7 @@ function stationManagement(initialStations, initialRecipes) {
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
active: this.form.active,
};
const resp = await fetch('/admin/api/stations', {
@@ -26,8 +26,50 @@
</p>
</div>
<!-- Login Form -->
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
<div x-data="pinLogin()" x-init="init()">
<!-- Vista Operatore: nome + PIN (#59) -->
<div x-show="mode === 'pin'" x-cloak>
<!-- Elenco nomi -->
<div x-show="!selected">
<p class="text-sm text-center text-[var(--text-secondary)] mb-4">{{ _('Chi sei?') }}</p>
<div class="grid grid-cols-2 gap-3" id="pin-user-list">
<template x-for="u in users" :key="u.id">
<button type="button" @click="select(u)"
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
</template>
</div>
</div>
<!-- Tastierino -->
<div x-show="selected" id="pin-pad">
<p class="text-center font-semibold mb-1" x-text="selected && selected.display_name"></p>
<p class="text-sm text-center text-[var(--text-secondary)] mb-3">{{ _('Inserisci il PIN') }}</p>
<div class="flex justify-center gap-3 mb-3" aria-hidden="true">
<template x-for="i in 4" :key="i">
<span class="h-4 w-4 rounded-full border-2 border-[var(--border-color)]"
:class="pin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
</template>
</div>
<p class="text-sm text-center text-red-600 min-h-[1.25rem] mb-2" role="alert" x-text="error"></p>
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
<template x-for="d in ['1','2','3','4','5','6','7','8','9']" :key="d">
<button type="button" @click="press(d)" :disabled="busy"
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
</template>
<button type="button" @click="back()" :disabled="busy"
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
<button type="button" @click="press('0')" :disabled="busy"
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
<button type="button" @click="del()" :disabled="busy"
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
</div>
</div>
<p class="mt-6 text-center">
<a href="#" @click.prevent="mode = 'password'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con password') }}</a>
</p>
</div>
<!-- Login Form (password: admin e chi non ha PIN) -->
<form x-show="mode === 'password'" method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Username -->
<div>
@@ -98,6 +140,11 @@
</div>
</form>
<p class="mt-4 text-center" x-show="mode === 'password' && users.length" x-cloak>
<a href="#" @click.prevent="mode = 'pin'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con PIN') }}</a>
</p>
</div>
<!-- Help Text -->
<div class="mt-6 text-center">
<p class="text-xs text-[var(--text-muted)]">
@@ -115,3 +162,15 @@
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
/* Configurazione e testi per pin-pad.js (#59) */
window.TMF_PIN = {
usersUrl: {{ url_for('auth.pin_users')|tojson }},
loginUrl: {{ url_for('auth.login_pin')|tojson }},
msgNet: {{ _('Errore di connessione al server')|tojson }}
};
</script>
<script src="{{ url_for('static', filename='js/pin-pad.js') }}"></script>
{% endblock %}
@@ -8,6 +8,8 @@
{% set icon_stations %}<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>{% endset %}
{% set icon_settings %}<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>{% endset %}
{% set icon_monitor %}<path stroke-linecap="round" stroke-linejoin="round" d="M9.348 14.652a3.75 3.75 0 010-5.304m5.304 0a3.75 3.75 0 010 5.304m-7.425 2.121a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>{% endset %}
{# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
<a href="{{ href }}"
@@ -31,8 +33,11 @@
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
{% endif %}
{# #57: Monitor produzione, visibile ad admin e capoturno (stessa regola di monitor_required) #}
{% if current_user.get('is_admin') or (current_user.get('roles') and 'Supervisor' in current_user.roles) %}
{{ nav_link(url_for('monitor.index'), _('Monitor'), icon_monitor, ep.startswith('monitor.'), mobile) }}
{% endif %}
{% if current_user.get('is_admin') %}
{{ nav_link(url_for('admin.user_list'), _('Utenti'), icon_users, ep == 'admin.user_list', mobile) }}
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }}
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
{% endif %}
@@ -503,6 +503,32 @@
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
<script>
// #56: stringhe e percorsi per il ritaglio prima dell'upload
ImageCrop.configure({
pdfSrc: '{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}',
workerSrc: '{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}',
labels: {
title: {{ _('Ritaglia immagine')|tojson }},
page: {{ _('Pagina')|tojson }},
hint: {{ _('Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.')|tojson }},
pdfNote: {{ _('Un PDF ritagliato viene salvato come immagine PNG.')|tojson }},
confirm: {{ _('Conferma ritaglio')|tojson }},
original: {{ _('Usa originale')|tojson }},
cancel: {{ _('Annulla')|tojson }},
loading: {{ _('Caricamento...')|tojson }},
error: {{ _('Impossibile aprire il file per il ritaglio.')|tojson }}
}
});
// #56: apre la modale di ritaglio; null = annullato, altrimenti il file da caricare
async function tmfCropBeforeUpload(file) {
const res = await ImageCrop.open(file);
if (res === null || res === file) return res;
const base = (file.name || 'file').replace(/\.[^.]+$/, '');
return new File([res], base + '-ritaglio.png', { type: 'image/png' });
}
</script>
<script>
function recipeEditor() {
return {
@@ -526,7 +552,8 @@ function recipeEditor() {
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
// ---- Measurement interval ----
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
// #49: a new recipe starts from the system default duration (default_interval), an existing one never does
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else (default_interval if not recipe and default_interval else 'null'))|tojson }},
// ---- Rules of measurement (points 8 and 9) ----
// A new recipe forbids typing: the caliper is the instrument, and permission
@@ -672,6 +699,10 @@ function recipeEditor() {
return;
}
// #56: scelta di ritaglio (immagine/PDF) prima dell'upload; senza ritaglio il file resta invariato
file = await tmfCropBeforeUpload(file);
if (!file) return;
this.uploadingFile = true;
this.errorMessage = '';
@@ -10,6 +10,51 @@
filter: 'all',
deleteModal: false,
deleteTarget: null,
// #52: duplicate modal state
dupModal: false,
dupSource: null,
dupCode: '',
dupName: '',
dupError: '',
dupBusy: false,
openDupModal(recipe) {
this.dupSource = recipe;
this.dupCode = recipe.code + '-COPIA';
this.dupName = recipe.name + ' ' + {{ _('(copia)')|tojson_attr }};
this.dupError = '';
this.dupModal = true;
},
async duplicateRecipe() {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
this.dupBusy = true;
this.dupError = '';
try {
const resp = await fetch(`/maker/api/recipes/${this.dupSource.id}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
body: JSON.stringify({ code: this.dupCode.trim(), name: this.dupName.trim() })
});
const data = await resp.json().catch(() => ({}));
if (resp.ok) {
// open the new recipe in the editor
window.location.href = '/maker/recipes/' + data.id + '/edit';
return;
}
if (resp.status === 409) {
this.dupError = {{ _('Esiste già una ricetta con questo codice')|tojson_attr }};
} else if (resp.status === 422) {
this.dupError = {{ _('Codice o nome non validi')|tojson_attr }};
} else {
this.dupError = data.detail || data.error || {{ _('Errore durante la duplicazione')|tojson_attr }};
}
} catch (err) {
this.dupError = {{ _('Errore di connessione')|tojson_attr }};
} finally {
this.dupBusy = false;
}
},
get filteredRecipes() {
let filtered = this.recipes;
@@ -265,6 +310,15 @@
{{ _('Versioni') }}
</a>
<!-- Duplica (#52) -->
<button @click="openDupModal(recipe)"
class="btn btn-secondary gap-1.5 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
{{ _('Duplica') }}
</button>
<!-- Spacer -->
<div class="flex-1"></div>
@@ -318,6 +372,42 @@
</a>
</div>
<!-- Duplicate Modal (#52) -->
<div x-show="dupModal"
x-cloak
@keydown.escape.window="dupModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true">
<div @click="dupModal = false" class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="flex min-h-full items-center justify-center p-4">
<div class="tmf-card max-w-lg w-full relative p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">
{{ _('Duplica ricetta') }}
</h3>
<div class="mb-3">
<label class="tmf-label" for="dup-code">{{ _('Codice') }}</label>
<input id="dup-code" type="text" x-model="dupCode" maxlength="100" class="tmf-input">
</div>
<div class="mb-3">
<label class="tmf-label" for="dup-name">{{ _('Nome') }}</label>
<input id="dup-name" type="text" x-model="dupName" maxlength="255" class="tmf-input">
</div>
<p x-show="dupError" x-text="dupError" class="text-sm text-red-600 mb-3"></p>
<div class="flex flex-col sm:flex-row gap-3">
<button @click="dupModal = false" class="btn btn-secondary flex-1 justify-center">
{{ _('Annulla') }}
</button>
<button @click="duplicateRecipe()"
:disabled="dupBusy || !dupCode.trim() || !dupName.trim()"
class="btn btn-primary flex-1 justify-center">
{{ _('Duplica') }}
</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div x-show="deleteModal"
x-cloak
@@ -91,6 +91,74 @@
{% endblock %}
{% block content %}
{# #53 #55: parte del form task comune a creazione e modifica; `d` e il nome dello stato Alpine (newTask / editTaskData) #}
{% macro task_flags_editor(d) %}
<div class="sm:col-span-2 space-y-3">
<!-- #53: il task entra nel ciclo di misura (timer e cicalino) -->
<label class="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" class="mt-0.5"
x-model="{{ d }}.in_loop"
@change="{{ d }}.in_loop_touched = true">
<span>{{ _('Nel ciclo di misura (timer e cicalino)') }}</span>
</label>
<!-- #55: opzioni valide solo per il tipo Nota -->
<template x-if="{{ d }}.task_type === 'note'">
<div class="space-y-3">
<label class="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" class="mt-0.5" x-model="{{ d }}.at_close">
<span>{{ _('Compila a fine produzione') }}</span>
</label>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<label class="tmf-label mb-0">{{ _('Campi da compilare') }}</label>
<button type="button" class="btn btn-secondary text-xs gap-1"
:disabled="{{ d }}.fields.length >= MAX_NOTE_FIELDS"
@click="addNoteField({{ d }})">
+ {{ _('Aggiungi campo') }}
</button>
</div>
<template x-for="(f, fi) in {{ d }}.fields" :key="f._uid">
<div class="grid grid-cols-1 sm:grid-cols-12 gap-2 p-2 rounded border border-[var(--border-color)] bg-[var(--bg-secondary)]">
<div class="sm:col-span-4">
<input type="text" class="tmf-input text-sm" maxlength="100"
placeholder="{{ _('Etichetta') }}"
x-model="f.label"
@input="deriveNoteFieldKey({{ d }}, f)">
<span class="block mt-0.5 text-[10px] font-mono text-[var(--text-muted)]" x-text="f.key"></span>
</div>
<div class="sm:col-span-2">
<select class="tmf-input text-sm" x-model="f.type">
<option value="text">{{ _('Testo') }}</option>
<option value="number">{{ _('Numero') }}</option>
</select>
</div>
<div class="sm:col-span-3">
<input type="text" class="tmf-input text-sm"
placeholder="{{ _('Valore precompilato') }}"
x-model="f.default">
</div>
<label class="sm:col-span-1 flex items-center gap-1 text-xs cursor-pointer">
<input type="checkbox" x-model="f.attachment">
<span>{{ _('Allegato') }}</span>
</label>
<div class="sm:col-span-2 flex items-center justify-end gap-1">
<button type="button" class="p-1 rounded hover:bg-[var(--bg-card-hover)]"
:disabled="fi === 0" @click="moveNoteField({{ d }}, fi, -1)"
title="{{ _('Sposta su') }}">&uarr;</button>
<button type="button" class="p-1 rounded hover:bg-[var(--bg-card-hover)]"
:disabled="fi === {{ d }}.fields.length - 1" @click="moveNoteField({{ d }}, fi, 1)"
title="{{ _('Sposta giu') }}">&darr;</button>
<button type="button" class="p-1 rounded text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
@click="removeNoteField({{ d }}, fi)"
title="{{ _('Rimuovi campo') }}">&times;</button>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
{% endmacro %}
<div class="tmf-page"
x-data="taskEditor()"
x-cloak>
@@ -273,10 +341,15 @@
<!-- Tipo: dichiarato qui, non dedotto dal contenuto -->
<div>
<label class="tmf-label">{{ _('Tipo') }} <span class="text-red-500">*</span></label>
<select x-model="newTask.task_type" class="tmf-input">
<select x-model="newTask.task_type" class="tmf-input"
@change="onTaskTypeChange(newTask, $event.target.value)">
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
<option value="camera_measure">{{ _('Misura camera') }}</option>
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
<!-- #54: task di controllo con esito OK/NOK, senza quote -->
<option value="check">{{ _('Controllo OK/NOK') }}</option>
</select>
<p class="mt-1 text-xs text-[var(--text-secondary)]">
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
@@ -304,6 +377,8 @@
rows="3"
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
</div>
{# #53 #55: ciclo di misura, compilazione a fine produzione e campi della nota #}
{{ task_flags_editor('newTask') }}
</div>
</div>
<div class="tmf-card-footer flex items-center gap-2 justify-end">
@@ -385,8 +460,20 @@
x-ref="editTaskTitleInput">
</template>
<!-- Badge: N misurazioni -->
<!-- #53: segno sui task nel ciclo di misura -->
<span x-show="task.in_loop" class="shrink-0 text-primary"
title="{{ _('Nel ciclo di misura (timer e cicalino)') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 4v5h5M20 20v-5h-5M5.5 15A8 8 0 0018.4 17.4M18.5 9A8 8 0 005.6 6.6"/>
</svg>
</span>
<!-- #55: nota compilata a fine produzione -->
<span x-show="task.at_close" class="badge badge-neutral text-xs shrink-0">{{ _('A fine produzione') }}</span>
<!-- Badge: N misurazioni (non per i task di controllo, senza quote) -->
<span class="badge badge-neutral text-xs shrink-0"
x-show="task.task_type !== 'check'"
x-text="(task.subtasks ? task.subtasks.length : 0) + ' {{ _('misurazioni') }}'">
</span>
@@ -460,6 +547,55 @@
<div class="tmf-card-body space-y-4">
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Ricetta visione') }}
</p>
<!-- Stato corrente: cartella, file, uscite -->
<template x-if="recipeDir">
<div class="text-xs space-y-1 mb-3">
<p><span class="text-[var(--text-secondary)]">{{ _('Cartella') }}:</span>
<span class="font-mono" x-text="recipeDir"></span></p>
<p x-show="files.length"><span class="text-[var(--text-secondary)]">{{ _('File') }}:</span>
<span class="font-mono" x-text="files.join(', ')"></span></p>
<p x-show="outputs.length"><span class="text-[var(--text-secondary)]">{{ _('Uscite numeriche') }}:</span></p>
<ul class="list-disc pl-5" x-show="outputs.length">
<template x-for="o in outputs" :key="o.key">
<li><span class="font-mono" x-text="o.key"></span>
<span class="text-[var(--text-muted)]" x-text="(o.quantity || o.unit) ? '(' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : ''"></span></li>
</template>
</ul>
</div>
</template>
<p x-show="!recipeDir && !loading" class="text-xs text-[var(--text-muted)] mb-3">
{{ _('Nessuna ricetta caricata') }}
</p>
<!-- Caricamento: ricetta .json + allegati -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Ricetta (.json)') }}</label>
<input type="file" accept=".json,application/json" class="tmf-input text-xs"
@change="onRecipe($event)">
</div>
<div>
<label class="tmf-label text-xs">{{ _('File allegati (DXF, modelli, calibrazione)') }}</label>
<input type="file" multiple class="tmf-input text-xs" @change="onAttachments($event)">
</div>
</div>
<div class="flex justify-end mt-3">
<button type="button" class="btn btn-primary text-xs" @click="upload()"
:disabled="!recipeFile || busy">{{ _('Carica ricetta') }}</button>
</div>
<!-- Errori 422 in chiaro -->
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
</ul>
</div>
</template>
<!-- ========== Task File Upload / Preview ========== -->
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
@@ -539,10 +675,14 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800">
<div>
<label class="tmf-label">{{ _('Tipo') }}</label>
<select x-model="editTaskData.task_type" class="tmf-input text-sm">
<select x-model="editTaskData.task_type" class="tmf-input text-sm"
@change="onTaskTypeChange(editTaskData, $event.target.value)">
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
<option value="camera_measure">{{ _('Misura camera') }}</option>
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
<option value="check">{{ _('Controllo OK/NOK') }}</option>
</select>
</div>
<div>
@@ -565,12 +705,14 @@
rows="3"
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
</div>
{# #53 #55: ciclo di misura, compilazione a fine produzione e campi della nota #}
{{ task_flags_editor('editTaskData') }}
</div>
</template>
<!-- ========== Subtask Table ========== -->
<div class="subtask-table-wrap">
<table class="tmf-table" x-show="task.subtasks && task.subtasks.length > 0">
<table class="tmf-table" x-show="task.task_type !== 'check' && task.subtasks && task.subtasks.length > 0">
<thead>
<tr>
<th class="w-16 text-center">{{ _('#') }}</th>
@@ -657,7 +799,7 @@
</div>
<!-- ========== Empty Subtask State ========== -->
<div x-show="!task.subtasks || task.subtasks.length === 0"
<div x-show="task.task_type !== 'check' && (!task.subtasks || task.subtasks.length === 0)"
class="text-center py-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full
bg-[var(--bg-secondary)] mb-3">
@@ -699,21 +841,29 @@
class="tmf-input"
placeholder="{{ _('Es. Diametro foro principale') }}">
</div>
<!-- Uscita visione (#46): solo task camera_measure / dxf_compare -->
<div class="col-span-2 sm:col-span-3 lg:col-span-2"
x-show="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<label class="tmf-label text-xs">{{ _('Uscita visione') }}</label>
<select x-model="newSubtask.vision_output" class="tmf-input">
<option value="">{{ _('Nessuna') }}</option>
<template x-for="o in (visionOutputs[task.id] || [])" :key="o.key">
<option :value="o.key" :selected="o.key === newSubtask.vision_output"
x-text="o.key + ((o.quantity || o.unit) ? ' (' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : '')"></option>
</template>
</select>
</div>
<!-- Measurement Type -->
<div>
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
<!-- #47: lista da Impostazioni (list_measurement_types) o voci di default; un valore salvato fuori lista resta selezionabile -->
<select x-model="newSubtask.measurement_type"
class="tmf-input">
<option value="">-</option>
<option value="linear">{{ _('Lineare') }}</option>
<option value="diameter">{{ _('Diametro') }}</option>
<option value="radius">{{ _('Raggio') }}</option>
<option value="angle">{{ _('Angolo') }}</option>
<option value="roughness">{{ _('Rugosita') }}</option>
<option value="torque">{{ _('Coppia') }}</option>
<option value="force">{{ _('Forza') }}</option>
<option value="weight">{{ _('Peso') }}</option>
<option value="other">{{ _('Altro') }}</option>
<template x-for="o in measurementTypeOptions()" :key="o.value">
<option :value="o.value" :selected="o.value === newSubtask.measurement_type"
x-text="o.label"></option>
</template>
</select>
</div>
<!-- Nominal -->
@@ -721,24 +871,20 @@
<label class="tmf-label text-xs">{{ _('Nominale') }}</label>
<input type="number" step="any"
x-model.number="newSubtask.nominal"
@input="onNominalInput($event)"
class="tmf-input measure-value text-right"
placeholder="0.000">
</div>
<!-- Unit -->
<div>
<label class="tmf-label text-xs">{{ _('Unita') }}</label>
<!-- #47: lista da Impostazioni (list_units) o voci di default; un valore salvato fuori lista resta selezionabile -->
<select x-model="newSubtask.unit"
class="tmf-input">
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="m">m</option>
<option value="μm">μm</option>
<option value="in">in</option>
<option value="°">°</option>
<option value="g">g</option>
<option value="kg">kg</option>
<option value="N">N</option>
<option value="Nm">Nm</option>
<template x-for="o in unitOptions()" :key="o.value">
<option :value="o.value" :selected="o.value === newSubtask.unit"
x-text="o.label"></option>
</template>
</select>
</div>
</div>
@@ -749,6 +895,7 @@
<label class="tmf-label text-xs text-red-500">{{ _('LTL') }}</label>
<input type="number" step="any"
x-model.number="newSubtask.ltl"
@input="tolAuto = false"
class="tmf-input measure-value text-right"
placeholder="{{ _('Lim. Tol. Inf.') }}">
</div>
@@ -770,10 +917,14 @@
<label class="tmf-label text-xs text-red-500">{{ _('UTL') }}</label>
<input type="number" step="any"
x-model.number="newSubtask.utl"
@input="tolAuto = false"
class="tmf-input measure-value text-right"
placeholder="{{ _('Lim. Tol. Sup.') }}">
</div>
</div>
<!-- #48: promemoria delle tolleranze generali (Impostazioni) -->
<p class="text-xs text-[var(--text-muted)] mt-2"
x-show="generalTolText()" x-text="generalTolText()"></p>
<!-- Form Actions -->
<div class="flex items-center gap-2 justify-end mt-4">
@@ -800,7 +951,7 @@
<!-- ========== Task Card Footer ========== -->
<div class="tmf-card-footer flex items-center justify-end">
<button @click="openAddSubtask(task)"
x-show="addingSubtaskForTask !== task.id"
x-show="addingSubtaskForTask !== task.id && task.task_type !== 'check'"
class="btn btn-secondary text-xs gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
@@ -1093,9 +1244,36 @@
{% block extra_js %}
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
<script>
// #56: stringhe e percorsi per il ritaglio prima dell'upload
ImageCrop.configure({
pdfSrc: '{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}',
workerSrc: '{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}',
labels: {
title: {{ _('Ritaglia immagine')|tojson }},
page: {{ _('Pagina')|tojson }},
hint: {{ _('Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.')|tojson }},
pdfNote: {{ _('Un PDF ritagliato viene salvato come immagine PNG.')|tojson }},
confirm: {{ _('Conferma ritaglio')|tojson }},
original: {{ _('Usa originale')|tojson }},
cancel: {{ _('Annulla')|tojson }},
loading: {{ _('Caricamento...')|tojson }},
error: {{ _('Impossibile aprire il file per il ritaglio.')|tojson }}
}
});
// #56: apre la modale di ritaglio; null = annullato, altrimenti il file da caricare
async function tmfCropBeforeUpload(file) {
const res = await ImageCrop.open(file);
if (res === null || res === file) return res;
const base = (file.name || 'file').replace(/\.[^.]+$/, '');
return new File([res], base + '-ritaglio.png', { type: 'image/png' });
}
</script>
<script>
// Force reload when page is restored from browser bfcache (back/forward navigation).
// This ensures fresh data is displayed after editing annotations in task_drawing.
@@ -1121,16 +1299,42 @@ function taskEditor() {
// ---- Task CRUD state ----
editingTask: null,
editTaskData: { title: '', directive: '', description: '', task_type: 'note' },
newTask: { title: '', directive: '', description: '', task_type: 'note' },
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
newTask: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
MAX_NOTE_FIELDS: 20, // #55: limite campi per nota (stesso del backend)
_fieldUid: 0,
// ---- Subtask CRUD state ----
editingSubtaskId: null,
addingSubtaskForTask: null,
newSubtask: {
marker_number: 1, description: '', measurement_type: '',
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm'
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm',
vision_output: ''
},
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
visionOutputs: {},
// ---- Liste e tolleranze generali dalle Impostazioni (#47, #48); null = non configurato ----
configuredTypes: {{ list_measurement_types|default(none)|tojson }},
configuredUnits: {{ list_units|default(none)|tojson }},
generalTolPlus: {{ general_tolerance_plus|default(none)|tojson }},
generalTolMinus: {{ general_tolerance_minus|default(none)|tojson }},
// #48: true = utl/ltl si riempiono da soli dal nominale (solo quota nuova, finche' non li tocca l'utente)
tolAuto: false,
// Voci scritte a mano usate quando la lista non e' configurata (#47)
defaultTypeOptions: [
{value: 'linear', label: {{ _('Lineare')|tojson }}},
{value: 'diameter', label: {{ _('Diametro')|tojson }}},
{value: 'radius', label: {{ _('Raggio')|tojson }}},
{value: 'angle', label: {{ _('Angolo')|tojson }}},
{value: 'roughness', label: {{ _('Rugosita')|tojson }}},
{value: 'torque', label: {{ _('Coppia')|tojson }}},
{value: 'force', label: {{ _('Forza')|tojson }}},
{value: 'weight', label: {{ _('Peso')|tojson }}},
{value: 'other', label: {{ _('Altro')|tojson }}}
],
defaultUnits: ['mm', 'cm', 'm', '\u03bcm', 'in', '\u00b0', 'g', 'kg', 'N', 'Nm'],
// ---- Delete modals ----
deleteTaskModal: false,
@@ -1153,6 +1357,10 @@ function taskEditor() {
// Init
// ============================================================
init() {
// Uscite della ricetta visione: evento dal componente visionRecipe (#46)
window.addEventListener('vision-outputs', (ev) => {
this.visionOutputs = { ...this.visionOutputs, [ev.detail.taskId]: ev.detail.outputs || [] };
});
// Sort tasks by order_index on load
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
@@ -1274,7 +1482,84 @@ function taskEditor() {
// ============================================================
resetNewTask() {
this.newTask = { title: '', directive: '', description: '', task_type: 'note' };
this.newTask = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
},
// ---- #53 #55: ciclo di misura e campi della nota ----
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
loopDefault(type) {
return ['measure', 'camera_measure', 'dxf_compare', 'check'].includes(type);
},
// Cambio tipo: il default del ciclo segue il tipo finche l'utente non lo tocca;
// at_close e campi esistono solo per la nota.
onTaskTypeChange(obj, type) {
obj.task_type = type;
if (!obj.in_loop_touched) obj.in_loop = this.loopDefault(type);
if (type !== 'note') { obj.at_close = false; obj.fields = []; }
},
// #55: key dall'etichetta (minuscole, [a-z0-9_], max 40), unica nella lista
_slugKey(label) {
const base = (label || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
return base || 'campo';
},
deriveNoteFieldKey(obj, f) {
if (f.locked) return; // la key non cambia piu dopo il primo salvataggio
const used = new Set(obj.fields.filter(x => x !== f).map(x => x.key));
const base = this._slugKey(f.label);
let key = base, n = 2;
while (used.has(key)) {
const suffix = '_' + n++;
key = base.slice(0, 40 - suffix.length) + suffix;
}
f.key = key;
},
addNoteField(obj) {
if (obj.fields.length >= this.MAX_NOTE_FIELDS) return;
const f = { _uid: ++this._fieldUid, key: '', label: '', type: 'text', default: '', attachment: false, locked: false };
obj.fields.push(f);
this.deriveNoteFieldKey(obj, f);
},
removeNoteField(obj, i) {
obj.fields.splice(i, 1);
},
moveNoteField(obj, i, dir) {
const j = i + dir;
if (j < 0 || j >= obj.fields.length) return;
const [f] = obj.fields.splice(i, 1);
obj.fields.splice(j, 0, f);
},
// Campi salvati -> stato di modifica (key bloccate)
_fieldsToState(fields) {
return (fields || []).map(f => ({
_uid: ++this._fieldUid, key: f.key, label: f.label || '', type: f.type || 'text',
default: f.default == null ? '' : f.default, attachment: !!f.attachment, locked: true
}));
},
// Stato -> corpo API (fields null se non e una nota o e vuota); ok=false con errorMessage se manca un'etichetta.
_fieldsPayload(obj) {
if (obj.task_type !== 'note') return { ok: true, fields: null };
const out = [];
for (const f of obj.fields) {
if (!f.label.trim()) {
this.errorMessage = {{ _("Ogni campo deve avere un'etichetta")|tojson }};
return { ok: false };
}
out.push({
key: f.key, label: f.label.trim(), type: f.type || 'text',
default: (f.default || '').trim() || null, attachment: !!f.attachment
});
}
return { ok: true, fields: out.length ? out : null };
},
// ============================================================
@@ -1353,8 +1638,10 @@ function taskEditor() {
async addTask() {
if (!this.newTask.title.trim()) return;
this.saving = true;
this.errorMessage = '';
const fp = this._fieldsPayload(this.newTask); // #55
if (!fp.ok) return;
this.saving = true;
try {
const resp = await fetch(`/maker/api/recipes/${this.recipeId}/tasks`, {
@@ -1367,7 +1654,11 @@ function taskEditor() {
title: this.newTask.title.trim(),
task_type: this.newTask.task_type || 'note',
directive: this.newTask.directive.trim() || null,
description: this.newTask.description.trim() || null
description: this.newTask.description.trim() || null,
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
in_loop: !!this.newTask.in_loop,
at_close: this.newTask.task_type === 'note' && !!this.newTask.at_close,
fields: fp.fields
})
});
@@ -1399,7 +1690,12 @@ function taskEditor() {
title: task.title,
task_type: task.task_type || 'note',
directive: task.directive || '',
description: task.description || ''
description: task.description || '',
// #53 #55: valore salvato (task vecchi senza il campo: default del tipo)
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
in_loop_touched: false,
at_close: !!task.at_close,
fields: this._fieldsToState(task.fields)
};
// Ensure expanded
this.expandedTask = task.id;
@@ -1407,13 +1703,15 @@ function taskEditor() {
cancelEditTask() {
this.editingTask = null;
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note' };
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
},
async updateTask(taskId) {
if (!this.editTaskData.title.trim()) return;
this.saving = true;
this.errorMessage = '';
const fp = this._fieldsPayload(this.editTaskData); // #55
if (!fp.ok) return;
this.saving = true;
try {
const resp = await fetch(`/maker/api/tasks/${taskId}`, {
@@ -1426,7 +1724,11 @@ function taskEditor() {
title: this.editTaskData.title.trim(),
task_type: this.editTaskData.task_type || 'note',
directive: this.editTaskData.directive.trim() || null,
description: this.editTaskData.description.trim() || null
description: this.editTaskData.description.trim() || null,
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
in_loop: !!this.editTaskData.in_loop,
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
fields: fp.fields
})
});
@@ -1599,8 +1901,61 @@ function taskEditor() {
return Math.max(...task.subtasks.map(s => s.marker_number || 0)) + 1;
},
// #47: opzioni della select Tipo; il valore corrente fuori lista viene aggiunto per non perderlo
measurementTypeOptions() {
const known = v => (this.defaultTypeOptions.find(d => d.value === v) || {label: v}).label;
const opts = this.configuredTypes
? this.configuredTypes.map(v => ({value: v, label: known(v)})) // un tipo di default noto mantiene l'etichetta tradotta
: this.defaultTypeOptions.slice();
const cur = this.newSubtask.measurement_type;
if (cur && !opts.some(o => o.value === cur)) opts.push({value: cur, label: known(cur)});
return opts;
},
// #47: opzioni della select Unita, stessa logica del Tipo
unitOptions() {
const opts = (this.configuredUnits || this.defaultUnits).map(v => ({value: v, label: v}));
const cur = this.newSubtask.unit;
if (cur && !opts.some(o => o.value === cur)) opts.push({value: cur, label: cur});
return opts;
},
// #47: unita di partenza di una quota nuova: la prima della lista configurata, altrimenti mm
defaultUnit() {
return this.configuredUnits ? this.configuredUnits[0] : 'mm';
},
// #48: testo "Tolleranze generali: +x / -y" sotto i campi; vuoto se non configurate
generalTolText() {
const parts = [];
if (this.generalTolPlus !== null) parts.push('+' + this.generalTolPlus);
if (this.generalTolMinus !== null) parts.push('\u2212' + this.generalTolMinus);
return parts.length ? {{ _('Tolleranze generali')|tojson }} + ': ' + parts.join(' / ') : '';
},
// #48: su quota nuova, scrivendo il nominale riempie utl = nominale + plus e ltl = nominale - minus
onNominalInput(ev) {
if (!this.tolAuto || this.editingSubtaskId) return;
const raw = String(ev.target.value).trim();
const nominal = parseFloat(raw);
if (raw === '' || !isFinite(nominal)) {
// nominale svuotato: i limiti riempiti da noi tornano vuoti
this.newSubtask.utl = null;
this.newSubtask.ltl = null;
return;
}
// decimali: quelli del nominale o delle tolleranze generali, al massimo 4
const dec = s => (String(s).split('.')[1] || '').length;
const decimals = Math.min(4, Math.max(dec(raw), dec(this.generalTolPlus), dec(this.generalTolMinus)));
const round = v => Number(v.toFixed(decimals));
if (this.generalTolPlus !== null) this.newSubtask.utl = round(nominal + this.generalTolPlus);
if (this.generalTolMinus !== null) this.newSubtask.ltl = round(nominal - this.generalTolMinus);
},
resetNewSubtask(taskId) {
const task = this.tasks.find(t => t.id === taskId);
// #48: automatismo attivo solo se c'e' almeno una tolleranza generale
this.tolAuto = this.generalTolPlus !== null || this.generalTolMinus !== null;
this.newSubtask = {
marker_number: task ? this.nextMarkerNumber(task) : 1,
description: '',
@@ -1610,7 +1965,8 @@ function taskEditor() {
uwl: null,
lwl: null,
ltl: null,
unit: 'mm'
unit: this.defaultUnit(), // #47
vision_output: ''
};
},
@@ -1634,7 +1990,8 @@ function taskEditor() {
uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm'
unit: this.newSubtask.unit || 'mm',
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
};
try {
@@ -1678,6 +2035,7 @@ function taskEditor() {
// Open the same form used for adding, but pre-filled with existing data
this.editingSubtaskId = subtask.id;
this.addingSubtaskForTask = taskId;
this.tolAuto = false; // #48: una quota esistente non viene mai ricalcolata
this.newSubtask = {
marker_number: subtask.marker_number,
description: subtask.description || '',
@@ -1687,7 +2045,8 @@ function taskEditor() {
uwl: subtask.uwl,
lwl: subtask.lwl,
ltl: subtask.ltl,
unit: subtask.unit || 'mm'
unit: subtask.unit || 'mm',
vision_output: subtask.vision_output || ''
};
},
@@ -1711,7 +2070,8 @@ function taskEditor() {
uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm'
unit: this.newSubtask.unit || 'mm',
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
};
try {
@@ -1810,6 +2170,10 @@ function taskEditor() {
return;
}
// #56: scelta di ritaglio (immagine/PDF) prima dell'upload; senza ritaglio il file resta invariato
file = await tmfCropBeforeUpload(file);
if (!file) return;
this.saving = true;
this.errorMessage = '';
@@ -13,6 +13,15 @@
barcodeInput: '',
barcodeLoading: false,
barcodeError: '',
/* #63/#64: ricetta scelta che richiede lotto/seriale; il pannello compare solo allora. */
selected: null,
needsTrace(recipe) {
return !!(recipe && (recipe.requires_lot || recipe.requires_serial));
},
choose(recipe) {
this.selected = recipe;
this.$nextTick(() => { const el = document.getElementById('trace-panel'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); });
},
get filteredRecipes() {
if (!this.search) return this.recipes;
const q = this.search.toLowerCase();
@@ -62,9 +71,12 @@
// The scanner is a way in like any other: a recipe that demands a lot
// demands it here too, or the rule would last exactly as long as it
// took someone to reach for the barcode reader.
const missing = this.missingTraceability(data);
if (missing.length) {
this.barcodeError = {{ _("Compila prima:")|tojson_attr }} + ' ' + missing.join(', ');
// #63/#64: se la ricetta chiede lotto/seriale mancanti, la si sceglie e
// compare il pannello con i soli campi richiesti.
if (this.missingTraceability(data).length) {
this.barcodeModal = false;
this.barcodeInput = '';
this.choose(data);
return;
}
this.barcodeModal = false;
@@ -149,50 +161,6 @@
</div>
</div>
<!-- Traceability (data entry, NOT a search filter) -->
<div class="tmf-card mb-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}</h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Lot Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
{{ _('Numero Lotto') }}
</label>
<input type="text"
x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}"
class="tmf-input font-mono">
</div>
<!-- Serial Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
{{ _('Numero Seriale') }}
</label>
<input type="text"
x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}"
class="tmf-input font-mono">
</div>
</div>
</div>
</div>
<!-- Results Count -->
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-[var(--text-secondary)]">
@@ -269,30 +237,25 @@
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
rounded-b-xl">
{# What this recipe insists on before anyone measures. Said on the card
rather than after the fact: a measurement taken without its lot cannot
be traced back, and finding that out later is finding it out too late. #}
<template x-if="missingTraceability(recipe).length">
<p class="mb-2 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300">
<svg class="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
<span x-text="{{ _('Compila prima:')|tojson_attr }} + ' ' + missingTraceability(recipe).join(', ')"></span>
</p>
{# #63/#64: una ricetta con lotto/seriale obbligatori si sceglie prima
(il pannello compare sotto la griglia); le altre partono subito. #}
<template x-if="needsTrace(recipe)">
<button type="button" @click="choose(recipe)"
:class="selected && selected.id === recipe.id ? 'ring-2 ring-primary' : ''"
class="btn btn-primary w-full justify-center text-sm font-semibold">
{{ _('Seleziona') }}
</button>
</template>
<template x-if="!needsTrace(recipe)">
<a :href="buildTaskUrl(recipe.id)"
class="btn btn-primary w-full justify-center text-sm font-semibold
transition-shadow duration-200 group-hover:shadow-md">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('AVVIA ATTIVITÀ') }}
</a>
</template>
<a :href="missingTraceability(recipe).length ? null : buildTaskUrl(recipe.id)"
:aria-disabled="missingTraceability(recipe).length > 0"
:class="missingTraceability(recipe).length
? 'opacity-40 cursor-not-allowed pointer-events-none'
: 'group-hover:shadow-md'"
class="btn btn-primary w-full justify-center text-sm font-semibold
transition-shadow duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('AVVIA IN SEQUENZA') }}
</a>
{# The whole list, for when the operator wants to see it or go back to a
task left unfinished. Deliberately the quiet one of the two. #}
@@ -306,6 +269,41 @@
</template>
</div>
<!-- Traceability (#63/#64): compare solo dopo aver scelto una ricetta con
requires_lot / requires_serial, con i soli campi richiesti. -->
<div id="trace-panel" x-show="selected && needsTrace(selected)" x-cloak
class="tmf-card mt-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}: <span x-text="selected ? selected.name : ''"></span></h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div x-show="selected && selected.requires_lot">
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
<input type="text" x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}" class="tmf-input font-mono">
</div>
<div x-show="selected && selected.requires_serial">
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
<input type="text" x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}" class="tmf-input font-mono">
</div>
</div>
<a :href="selected && !missingTraceability(selected).length ? buildTaskUrl(selected.id) : null"
:aria-disabled="!selected || missingTraceability(selected).length > 0"
:class="(!selected || missingTraceability(selected).length) ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''"
class="btn btn-primary w-full justify-center text-sm font-semibold mt-4">
{{ _('AVVIA ATTIVITÀ') }}
</a>
</div>
</div>
<!-- Empty State -->
<div x-show="filteredRecipes.length === 0" x-cloak
class="text-center py-16">
@@ -183,7 +183,7 @@
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
<tr>
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
{{ m.measured_at|local_dt }}
</td>
<td style="color: var(--text-secondary);" class="text-sm">
{{ m.task_info.title if m.task_info else '-' }}

Some files were not shown because too many files have changed in this diff Show More