Compare commits

...

47 Commits

Author SHA1 Message Date
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 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 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
117 changed files with 7177 additions and 718 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}
+69 -9
View File
@@ -1,13 +1,15 @@
"""Statistics router - SPC endpoints for Metrologist dashboard."""
from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_metrologist
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.user import User
@@ -37,6 +39,7 @@ async def _query_measurements(
operator_id: int | None = None,
lot_number: str | None = None,
serial_number: str | None = None,
production_run_id: int | None = None, # #58: solo le misure di una sessione
) -> list[Measurement]:
"""Query measurements with common filters.
@@ -66,6 +69,9 @@ async def _query_measurements(
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if production_run_id is not None:
filters.append(Measurement.production_run_id == production_run_id)
query = (
select(Measurement)
.where(and_(*filters) if filters else True)
@@ -100,11 +106,12 @@ async def get_summary(
recipe_id: int = Query(...),
version_id: int | None = Query(None),
subtask_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> SummaryData:
@@ -112,6 +119,7 @@ async def get_summary(
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
pass_fail_values = [m.pass_fail for m in measurements]
return compute_summary(pass_fail_values)
@@ -122,11 +130,12 @@ async def get_capability(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> CapabilityData:
@@ -134,6 +143,7 @@ async def get_capability(
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
values = [float(m.value) for m in measurements]
tol = await _get_subtask_tolerances(db, subtask_id)
@@ -145,11 +155,12 @@ async def get_control_chart(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> ControlChartData:
@@ -157,6 +168,7 @@ async def get_control_chart(
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
values = [float(m.value) for m in measurements]
timestamps = [m.measured_at for m in measurements]
@@ -172,11 +184,12 @@ async def get_histogram(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
production_run_id: int | None = Query(None), # #58
n_bins: int = Query(20, ge=5, le=100),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
@@ -185,6 +198,7 @@ async def get_histogram(
measurements = await _query_measurements(
db, recipe_id, version_id, subtask_id,
date_from, date_to, operator_id, lot_number, serial_number,
production_run_id=production_run_id, # #58
)
values = [float(m.value) for m in measurements]
return compute_histogram(values, n_bins)
@@ -234,3 +248,49 @@ async def get_recipe_subtasks(
})
return subtasks_list
@router.get("/runs")
async def get_recipe_runs(
recipe_id: int = Query(...),
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> list[dict]:
"""#58: production runs of a recipe (for the "Sessione" filter), newest first."""
runs = (await db.execute(
select(ProductionRun)
.where(ProductionRun.recipe_id == recipe_id)
.order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc())
)).scalars().all()
# #58: conta solo le misure dei run della ricetta, non tutta la tabella.
run_ids = [r.id for r in runs]
counts = {}
if run_ids:
counts = dict((await db.execute(
select(Measurement.production_run_id, func.count(Measurement.id))
.where(Measurement.production_run_id.in_(run_ids))
.group_by(Measurement.production_run_id)
)).all())
operator_ids = {r.operator_id for r in runs}
names = {}
if operator_ids:
names = dict((await db.execute(
select(User.id, User.display_name).where(User.id.in_(operator_ids))
)).all())
return [
{
"id": r.id,
"station_id": r.station_id,
"station_name": r.station.name if r.station else None,
"operator_id": r.operator_id,
"operator_name": names.get(r.operator_id),
"started_at": r.started_at.isoformat() if r.started_at else None,
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
"status": r.status,
"lot_number": r.lot_number,
"serial_number": r.serial_number,
"cycle_count": r.cycle_count,
"measurement_count": counts.get(r.id, 0),
}
for r in runs
]
+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')
+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
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Station and StationRecipeAssignment."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
@@ -29,7 +29,7 @@ class StationResponse(BaseModel):
notes: Optional[str]
active: bool
created_by: int
created_at: datetime
created_at: UtcDateTime
class StationRecipeAssignmentCreate(BaseModel):
@@ -42,7 +42,7 @@ class StationRecipeAssignmentResponse(BaseModel):
station_id: int
recipe_id: int
assigned_by: int
assigned_at: datetime
assigned_at: UtcDateTime
class RecipeSummary(BaseModel):
+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)
+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 [])
+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),
)
+214 -7
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
@@ -46,7 +47,13 @@ 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,
) -> 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,12 +67,19 @@ 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)}
if recipe_dir:
data["recipe_dir"] = recipe_dir
if calibration:
data["calibration"] = json.dumps(calibration)
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{settings.vision_worker_url}/run",
files={"image": ("frame.png", image_bytes, "image/png")},
data={"graph": json.dumps(graph)},
data=data,
)
except httpx.TimeoutException as exc:
raise HTTPException(
@@ -122,6 +136,188 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
return report
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir
refused) is folded into ok=False; transport faults become 502/504 as in
`_call_worker`. Replaced by the tests.
"""
data = {"graph": json.dumps(graph)}
if recipe_dir:
data["recipe_dir"] = recipe_dir
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{settings.vision_worker_url}/validate", data=data,
)
except httpx.TimeoutException as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"the vision worker did not answer in time: {exc}",
) from exc
except httpx.HTTPError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc}",
) from exc
if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT:
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
errors = detail if isinstance(detail, list) else [str(detail)]
return {"ok": False, "errors": [str(e) for e in errors], "outputs": []}
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker refused the job: {response.text}",
)
try:
body = response.json()
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: not valid JSON",
) from exc
if not isinstance(body, dict) or "ok" not in body:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: missing ok",
)
body.setdefault("errors", [])
body.setdefault("outputs", [])
return body
# Task types that run a vision graph (#46).
VISION_TASK_TYPES = ("camera_measure", "dxf_compare")
def _safe_join(root: Path, relative: str) -> Path:
"""root/relative, refusing absolute paths, '..' and anything leaving root."""
candidate = Path(relative)
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
raise ValueError(f"unsafe path: {relative}")
full = (root / candidate).resolve()
if not full.is_relative_to(root.resolve()):
raise ValueError(f"unsafe path: {relative}")
return full
def collect_meta_paths(node: object) -> list[str]:
"""Every `path` string found in any dict under the recipe's `meta`."""
found: list[str] = []
if isinstance(node, dict):
for key, value in node.items():
if key == "path" and isinstance(value, str):
found.append(value)
else:
found.extend(collect_meta_paths(value))
elif isinstance(node, list):
for item in node:
found.extend(collect_meta_paths(item))
return found
def match_recipe_files(
recipe: dict, uploads: list[tuple[str, bytes]],
) -> tuple[dict[str, bytes], list[str]]:
"""Pair each uploaded file with a meta path by base name (#46).
Returns ({relative path: bytes}, errors). Errors list every upload that
has no name or matches no meta path, and every unsafe meta path. A meta
path left without a file is not an error here: /validate decides.
"""
paths = collect_meta_paths(recipe.get("meta", {}))
by_name: dict[str, str] = {}
for p in paths:
by_name.setdefault(Path(p.replace("\\", "/")).name, p)
placed: dict[str, bytes] = {}
errors: list[str] = []
for name, content in uploads:
if not name:
errors.append("a file has no name")
continue
target = by_name.get(Path(name.replace("\\", "/")).name)
if target is None:
errors.append(f"file {name!r} matches no path in the recipe's meta")
continue
try:
_safe_join(Path("."), target)
except ValueError:
errors.append(f"meta path {target!r} is not allowed")
continue
placed[target] = content
return placed, errors
def store_recipe_folder(
task_id: int, recipe: dict, files: dict[str, bytes],
) -> str:
"""Write the immutable recipe folder; return its path relative to uploads.
Folder name = first 16 hex of the recipe's sha256. If it already exists it
is left untouched (immutable: other task versions may reference it).
"""
canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":"))
# Il digest copre anche i file (path ordinato + sha256 del contenuto): la
# stessa ricetta con file diversi (es. primo upload senza il DXF) deve dare
# una cartella diversa, altrimenti quella incompleta resterebbe per sempre
# perche' immutabile (#46).
h = hashlib.sha256(canonical.encode("utf-8"))
for rel in sorted(files):
h.update(b"\0" + rel.encode("utf-8") + b"\0")
h.update(hashlib.sha256(files[rel]).digest())
digest = h.hexdigest()[:16]
relative = f"vision/recipes/{task_id}/{digest}"
folder = settings.upload_path / relative
if folder.exists():
return relative
folder.mkdir(parents=True, exist_ok=True)
(folder / "recipe.json").write_text(
json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8",
)
for rel, content in files.items():
target = _safe_join(folder, rel)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
return relative
def list_recipe_files(recipe_dir: str | None) -> list[str]:
"""Files of a recipe folder, relative to it (recipe.json excluded)."""
if not recipe_dir:
return []
try:
folder = _safe_join(settings.upload_path, recipe_dir)
except ValueError:
return []
if not folder.is_dir():
return []
return sorted(
p.relative_to(folder).as_posix()
for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json"
)
def save_result_files(
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
) -> tuple[str, str]:
"""Save the executed image and its overlay JSON side by side (#46).
Returns (image_path, overlay_path), both relative to uploads.
"""
stem = uuid4().hex
base = Path("vision") / "results" / str(task_id)
(settings.upload_path / base).mkdir(parents=True, exist_ok=True)
image_rel = (base / f"{stem}.{image_ext}").as_posix()
overlay_rel = (base / f"{stem}.overlay.json").as_posix()
(settings.upload_path / image_rel).write_bytes(image_bytes)
(settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8")
return image_rel, overlay_rel
async def execute_task(
db: AsyncSession,
task: RecipeTask,
@@ -132,8 +328,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",
@@ -210,7 +407,11 @@ async def execute_task(
),
)
report = await _call_worker(image_bytes, task.vision_json)
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
# passa `calibration`, la ricetta porta la sua (#46).
report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse
@@ -231,8 +432,12 @@ async def execute_task(
),
)
overlay = report.get("overlay") or []
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
result = VisionResult(
task_id=task.id,
image_path=image_path,
overlay_path=overlay_path,
engine_version=report["engine_version"],
executed_on="server",
graph_snapshot=task.vision_json,
@@ -257,7 +462,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 +582,6 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph",
)
return await _call_worker(image_bytes, task.vision_json)
return await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
+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
+126
View File
@@ -107,3 +107,129 @@ 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")
+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() == []
+167
View File
@@ -0,0 +1,167 @@
"""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.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
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
):
+76
View File
@@ -211,3 +211,79 @@ class TestDateFilter:
assert resp.status_code == 200
# All measurements should be within this wide range
assert resp.json()["total"] >= 2
class TestRunFilter:
"""#58: statistiche per sessione di produzione."""
async def _setup(self, db_session, user, metro):
from datetime import datetime
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.station import Station
from src.backend.models.orm.recipe import RecipeVersion
from sqlalchemy import select
recipe = await create_test_recipe(db_session, user.id)
version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().first()
station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id)
db_session.add(station)
await db_session.commit()
runs = []
for i, lot in enumerate(["L1", "L2"]):
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=user.id, status="closed", lot_number=lot,
started_at=datetime(2026, 1, 1 + i),
)
db_session.add(run)
runs.append(run)
await db_session.commit()
return recipe, runs
async def test_filter_by_run_and_unfiltered(
self, client, measurement_tec_user, metrologist_user, db_session,
):
from src.backend.models.orm.measurement import Measurement
recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user)
subtask_id, version_id = await _seed_measurements(
client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2],
)
# Assegna le prime due misure al run 1, la terza al run 2.
from sqlalchemy import select
ms = (await db_session.execute(
select(Measurement).order_by(Measurement.id)
)).scalars().all()
ms[0].production_run_id = runs[0].id
ms[1].production_run_id = runs[0].id
ms[2].production_run_id = runs[1].id
await db_session.commit()
h = auth_headers(metrologist_user)
r1 = await client.get("/api/statistics/summary", headers=h,
params={"recipe_id": recipe.id, "production_run_id": runs[0].id})
assert r1.json()["total"] == 2
r2 = await client.get("/api/statistics/summary", headers=h,
params={"recipe_id": recipe.id, "production_run_id": runs[1].id})
assert r2.json()["total"] == 1
r_all = await client.get("/api/statistics/summary", headers=h,
params={"recipe_id": recipe.id})
assert r_all.json()["total"] == 3
runs_resp = await client.get("/api/statistics/runs", headers=h,
params={"recipe_id": recipe.id})
assert runs_resp.status_code == 200
data = runs_resp.json()
assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima
assert [r["measurement_count"] for r in data] == [1, 2]
assert data[0]["lot_number"] == "L2"
assert data[0]["station_name"] == "Stazione 58"
assert data[0]["closed_at"] is None
async def test_runs_require_metrologist(
self, client, measurement_tec_user, db_session,
):
resp = await client.get("/api/statistics/runs",
headers=auth_headers(measurement_tec_user),
params={"recipe_id": 1})
assert resp.status_code == 403
+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(
@@ -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.).
+17 -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"])
+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):
+67 -1
View File
@@ -1,8 +1,10 @@
"""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,
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
request, session, url_for,
)
from flask_babel import gettext as _
@@ -820,3 +822,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)
@@ -50,7 +50,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 +127,27 @@ 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)
# ============================================================================
# PROXY REPORT PDF → FastAPI
# ============================================================================
@@ -137,7 +158,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,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">
@@ -102,6 +91,12 @@
<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>
<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'
@@ -332,210 +327,51 @@
</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>
</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,258 @@
<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: '',
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;
}
},
// #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;
}
},
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,31 @@
{% 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>
<div x-show="tab === 'generale'" class="max-w-4xl mx-auto" 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 +57,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 +133,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 +182,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 +212,5 @@ function systemSettings() {
};
}
</script>
{% include "admin/_users_script.html" %}
{% endblock %}
@@ -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 %}
@@ -32,7 +32,6 @@
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), 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 '-' }}
@@ -115,20 +115,12 @@
</svg>
{{ _('Lista task') }}
</a>
<button @click="goToNextTask()"
:disabled="!canLeaveTask"
:title="canLeaveTask ? '' : blockedReason"
:class="canLeaveTask ? '' : 'opacity-40 cursor-not-allowed'"
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
<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="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
</button>
{# #60: un solo «Completato», quello grande in fondo (touch); qui resta solo la lista. #}
</div>
{# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="isMeasureTask">
<template x-if="isMeasureTask && productionRun">
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
<div class="shrink-0 flex items-center gap-1.5">
{# While the line is stopped this becomes the way to restart it, so the
capoturno never has to hunt for a second control. #}
@@ -191,7 +183,8 @@
{# ================================================================
MAIN — 3-column layout
================================================================ #}
<div class="flex-1 flex overflow-hidden">
{# #46: camera_measure / dxf_compare get the acquisition panel below instead of the manual layout #}
<div class="flex-1 flex overflow-hidden" x-show="!isVisionTask">
{# ──────────────────────────────────────────────
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
@@ -451,6 +444,63 @@
</div>
{# ================================================================
VISION PANEL (#46) - acquire an image, run the recipe server-side, show the
quotes and the overlay. acquire(blob) is the entry point the agent camera
(#3) will reuse.
================================================================ #}
<div x-show="isVisionTask" x-cloak
x-data="visionExecute()"
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
<div class="flex-1 min-w-0 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
<input type="file" accept="image/*" capture="environment" class="hidden"
@change="onPick($event)" :disabled="busy">
</label>
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
</div>
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div>
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
</div>
</div>
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
<div class="text-sm font-bold text-[var(--text-primary)]">
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
</div>
<table class="w-full text-sm" x-show="rows.length">
<template x-for="r in rows" :key="r.label">
<tr>
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
</tr>
</template>
</table>
<div x-show="result && result.failures && result.failures.length"
class="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-xs">
<div class="font-bold mb-1">{{ _('Strumenti falliti') }}</div>
<template x-for="f in ((result && result.failures) || [])" :key="f.tool_id">
<div><span class="font-mono" x-text="f.tool_name || f.tool_id"></span>: <span x-text="f.error"></span></div>
</template>
</div>
</div>
</div>
{# ================================================================
MEASUREMENT TIMER BANNER
================================================================ #}
@@ -698,10 +748,12 @@
</div>
</template>
<template x-if="!isMeasureTask || subtasks.length === 0">
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
<button @click="goToNextTask()"
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
:disabled="!canLeaveTask"
class="btn text-lg font-bold flex-1 w-full min-h-[64px] justify-center gap-2 shadow-md
bg-measure-pass text-white hover:opacity-90">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
@@ -787,6 +839,10 @@
</div>
</div>
{# #61: se il server rifiuta l'avvio produzione, l'errore si vede qui, nell'overlay #}
<p x-show="productionError" x-cloak
class="mb-2 text-sm text-red-700 dark:text-red-300 text-center" x-text="productionError"></p>
<div class="flex gap-2">
<button @click="goToSummary()"
class="btn btn-secondary flex-1 justify-center gap-2">
@@ -912,6 +968,18 @@
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
{# #46: acquisition + overlay for camera_measure / dxf_compare #}
<script>
window.__visionCfg = {
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|tojson }},
subtasks: window.__taskSubtasks,
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
};
</script>
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
{# The clock's rules - how a delay reads, when the buzzer goes, where the
measurement lives - are shared with the other screens rather than restated. #}
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
@@ -1180,11 +1248,18 @@ function taskExecute() {
async loadProductionRun() {
try {
const resp = await fetch('{{ url_for("measure.api_current_production") }}');
if (!resp.ok) return;
if (!resp.ok) {
// #61: si mostra il detail del server (es. 503 "Stazione non configurata"),
// che e' la causa vera; fallback generico se il corpo non e' JSON.
const errBody = await resp.json().catch(() => null);
this.productionError = (errBody && errBody.detail) || '{{ _("Errore di comunicazione con il server") }}';
return;
}
const run = await resp.json();
if (run && run.id) this.adoptProductionRun(run);
} catch (e) {
// Offline or server down: leave the page usable, just without the timer.
// #61: offline o server giu: pagina usabile, ma l'errore e' visibile.
this.productionError = '{{ _("Errore di connessione") }}';
}
},
@@ -1697,13 +1772,9 @@ function taskExecute() {
this.showCompletionOverlay = false;
const opened = await this.startProduction();
if (!opened) {
// Server refused: keep the operator working rather than stranding them
// mid-shift, but productionError is on screen so it is not silent.
this.cycleConfirmed = true;
this.cycleCount++;
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
}
// #61: server rifiuta -> niente timer locale finto; si riapre l'overlay
// con l'errore (productionError) cosi' l'operatore puo' riprovare.
this.showCompletionOverlay = true;
return;
}
// The START-phase measurements have just been taken: record them as the first
@@ -1751,6 +1822,12 @@ function taskExecute() {
// ---- Open/close supervisor modal (always reset fields) ----
openSupervisorModal(action) {
// #61: fermo/ripresa/fine produzione agiscono su un run: senza run aperto
// si dice subito, prima di chiedere le credenziali.
if (['fermo_linea', 'ripresa', 'fine_produzione'].includes(action) && !this.productionRun) {
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
return;
}
this.supervisorAction = action;
this.supervisorUsername = '';
this.supervisorPassword = '';
@@ -1776,6 +1853,12 @@ function taskExecute() {
return declared === 'measure' || declared === 'camera_measure';
},
/* #46: image-based tasks run a VisionSuite recipe on the server. */
get isVisionTask() {
const t = this.task.task_type;
return t === 'camera_measure' || t === 'dxf_compare';
},
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
loud rather than silently rendering an empty screen. */
get isMeasureTaskWithoutQuotes() {
@@ -104,7 +104,7 @@
<svg class="w-6 h-6" 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') }}
{{ _('AVVIA ATTIVITÀ') }}
</a>
{% endif %}
{% endif %}
@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block title %}{{ _("Monitor stazioni") }} — TieMeasureFlow{% endblock %}
{% block content %}
<div class="tmf-page" x-data="stationMonitor()" x-init="init()">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _("Monitor stazioni") }}</h1>
<p class="text-sm text-steel mt-1">{{ _("Produzioni aperte, aggiornate ogni 10 secondi") }}</p>
</div>
<div class="text-xs text-steel" x-text="updatedLabel"></div>
</div>
<div x-show="error" class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm" x-text="error"></div>
<div x-show="loaded && runs.length === 0 && !error"
class="p-8 text-center text-steel bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)]">
{{ _("Nessuna stazione in produzione") }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<template x-for="r in runs" :key="r.id">
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] border-l-8 shadow-sm p-4"
:class="borderClass(r)">
<div class="flex items-start justify-between gap-2">
<div>
<div class="text-lg font-bold text-[var(--text-primary)]" x-text="r.station_code + ' — ' + r.station_name"></div>
<div class="text-sm text-steel" x-text="r.recipe_code + ' ' + r.recipe_name + ' (v' + r.version_number + ')'"></div>
</div>
<span class="text-xs font-semibold px-2 py-1 rounded"
:class="r.overdue ? 'bg-red-600 text-white animate-pulse' : 'bg-gray-200 text-gray-700'"
x-text="statusLabel(r)"></span>
</div>
<dl class="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-sm">
<dt class="text-steel">{{ _("Operatore") }}</dt><dd x-text="r.operator"></dd>
<dt class="text-steel">{{ _("Avviata alle") }}</dt><dd x-text="startedAt(r)"></dd>
<dt class="text-steel">{{ _("Durata") }}</dt><dd x-text="fmt(r.duration_seconds)"></dd>
<dt class="text-steel">{{ _("Cicli") }}</dt><dd x-text="r.cycle_count"></dd>
<dt class="text-steel">{{ _("Prossima misura") }}</dt>
<dd :class="r.overdue ? 'text-red-600 font-bold' : ''" x-text="timerLabel(r)"></dd>
<dt class="text-steel">{{ _("Ultimo evento") }}</dt>
<dd x-text="r.last_event ? r.last_event.event_type : '-'"></dd>
</dl>
<div class="mt-3 flex gap-2 text-xs font-semibold">
<span class="px-2 py-1 rounded bg-green-100 text-green-800" x-text="'OK ' + r.pass_count"></span>
<span class="px-2 py-1 rounded bg-yellow-100 text-yellow-800" x-text="'{{ _("Avviso") }} ' + r.warning_count"></span>
<span class="px-2 py-1 rounded bg-red-100 text-red-800" x-text="'NOK ' + r.fail_count"></span>
</div>
<ul class="mt-3 space-y-1 text-sm">
<template x-for="s in r.subtasks" :key="s.subtask_id">
<li class="flex justify-between gap-2">
<span class="truncate" x-text="s.description"></span>
<span class="font-mono whitespace-nowrap"
:class="s.in_tolerance ? 'text-green-700' : 'text-red-700 font-bold'"
x-text="s.mean.toFixed(3) + ' ' + s.unit"></span>
</li>
</template>
</ul>
</div>
</template>
</div>
</div>
<script>
function stationMonitor() {
return {
runs: [], loaded: false, error: '', updatedLabel: '',
init() { this.load(); setInterval(() => this.load(), 10000); },
async load() {
try {
const resp = await fetch('/monitor/api/runs');
const data = await resp.json();
if (!resp.ok || data.error) { this.error = data.detail || {{ _("Errore di caricamento")|tojson }}; return; }
this.runs = data; this.error = ''; this.loaded = true;
this.updatedLabel = new Date().toLocaleTimeString();
} catch (e) { this.error = {{ _("Errore di rete")|tojson }}; }
},
// Green in tolerance, yellow with warnings, red with fails or a mean out of tolerance.
borderClass(r) {
if (r.fail_count > 0 || r.subtasks.some(s => !s.in_tolerance)) return 'border-l-red-600';
if (r.warning_count > 0) return 'border-l-yellow-500';
return 'border-l-green-600';
},
statusLabel(r) {
if (r.overdue) return {{ _("IN RITARDO")|tojson }};
return r.status === 'paused' ? {{ _("In pausa")|tojson }} : {{ _("In corso")|tojson }};
},
startedAt(r) { return new Date(r.started_at + 'Z').toLocaleTimeString(); },
timerLabel(r) {
if (r.seconds_to_next_measurement === null) return '-';
const s = r.seconds_to_next_measurement;
return (s < 0 ? '-' : '') + this.fmt(Math.abs(s));
},
fmt(sec) {
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
return (h ? h + 'h ' : '') + m + 'm ' + String(s).padStart(2, '0') + 's';
},
};
}
</script>
{% endblock %}
@@ -35,6 +35,19 @@
</select>
</div>
<!-- Sessione di produzione (#58) -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Sessione") }}</label>
<select x-model="selectedRunId"
:disabled="runs.length === 0"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
<option value="">{{ _("Tutte le sessioni") }}</option>
<template x-for="r in runs" :key="r.id">
<option :value="r.id" x-text="runLabel(r)"></option>
</template>
</select>
</div>
<!-- Subtask -->
<div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
@@ -258,6 +271,8 @@ function spcDashboard() {
// Filter state
selectedRecipeId: '',
selectedSubtaskId: '',
selectedRunId: '', // #58
runs: [], // #58 sessioni della ricetta
dateFrom: '',
dateTo: '',
subtasks: [],
@@ -281,6 +296,8 @@ function spcDashboard() {
async onRecipeChange() {
this.selectedSubtaskId = '';
this.selectedRunId = ''; // #58
this.runs = [];
this.subtasks = [];
this.hasData = false;
this.summary = null;
@@ -290,6 +307,15 @@ function spcDashboard() {
if (!this.selectedRecipeId) return;
// #58: sessioni della ricetta per il filtro «Sessione»
try {
const rresp = await fetch('/statistics/api/runs?recipe_id=' + this.selectedRecipeId);
const rdata = await rresp.json();
if (Array.isArray(rdata)) this.runs = rdata;
} catch (e) {
console.error('Error loading runs:', e);
}
// Load subtasks for dropdown
try {
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
@@ -302,6 +328,12 @@ function spcDashboard() {
}
},
// #58: etichetta sessione = data, stazione, operatore, lotto
runLabel(r) {
const d = r.started_at ? new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
return [d, r.station_name, r.operator_name, r.lot_number].filter(Boolean).join(' · ');
},
async loadData() {
if (!this.selectedRecipeId) return;
@@ -312,8 +344,9 @@ function spcDashboard() {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
try {
// Parallel fetch of all endpoints
@@ -417,8 +450,9 @@ function spcDashboard() {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
const url = '/statistics/api/report-' + type + '?' + params.toString();
const resp = await fetch(url);
@@ -0,0 +1,64 @@
"""#50/#51: tab Utenti dentro Impostazioni, redirect da /admin/users, reset password."""
from unittest.mock import MagicMock, patch
import pytest
from tests.conftest import api_get_router
USER = {
"id": 2, "username": "mario", "display_name": "Mario", "email": None,
"roles": ["MeasurementTec"], "is_admin": False, "language_pref": "it",
"theme_pref": "light", "active": True,
}
@pytest.fixture
def mock_admin_api():
mock = MagicMock()
mock.get.side_effect = api_get_router({
"/api/users": [USER],
"/api/settings": {"auto_logout_minutes": "30"},
})
with patch("blueprints.admin.api_client", mock):
yield mock
def test_users_url_redirects_to_settings_tab(logged_in_client, mock_admin_api):
resp = logged_in_client.get("/admin/users")
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/admin/settings?tab=utenti")
def test_settings_has_users_tab_and_reset_for_admin(logged_in_client, mock_admin_api):
resp = logged_in_client.get("/admin/settings?tab=utenti")
assert resp.status_code == 200
html = resp.data.decode("utf-8")
assert "userManagement(" in html # pannello utenti incluso
assert "openReset(user)" in html # pulsante reset password (#51)
assert "mario" in html # utente caricato
assert "tab: " in html and "utenti" in html # tab iniziale richiesta
def test_navbar_has_no_users_link(logged_in_client, mock_admin_api):
html = logged_in_client.get("/admin/settings").data.decode("utf-8")
assert 'href="/admin/users"' not in html
def test_non_admin_cannot_reach_users_tab(client, mock_admin_api):
with client.session_transaction() as sess:
sess["api_key"] = "k"
sess["user"] = {"id": 3, "username": "op", "roles": ["MeasurementTec"],
"is_admin": False, "language_pref": "it", "theme_pref": "light"}
for url in ("/admin/settings?tab=utenti", "/admin/users"):
resp = client.get(url)
assert resp.status_code == 302
assert "/admin/" not in resp.headers["Location"]
mock_admin_api.get.assert_not_called()
def test_password_reset_proxy(logged_in_client, mock_admin_api):
mock_admin_api.put.return_value = {"message": "ok"}
resp = logged_in_client.put("/admin/api/users/2/password", json={"password": "Nuova1234"})
assert resp.status_code == 200
mock_admin_api.put.assert_called_once_with(
"/api/users/2/password", data={"password": "Nuova1234"})
+64
View File
@@ -136,3 +136,67 @@ class TestLanguageSwitch:
with client.session_transaction() as sess:
# Invalid language should NOT be stored
assert sess.get("language") != "xx"
class TestPinLogin:
"""Login Operatore con nome + PIN (#59)."""
USER = {
"id": 7, "username": "mario", "display_name": "Mario Rossi",
"roles": ["MeasurementTec"], "is_admin": False,
"language_pref": "it", "theme_pref": "light", "active": True,
}
def test_login_page_has_pin_view_and_password_link(self, client):
resp = client.get("/auth/login")
assert b"pin-pad.js" in resp.data
assert b"pin-pad" in resp.data # tastierino presente nel markup
assert b'name="password"' in resp.data # form password ancora presente
def test_pin_users_listed(self, client, mock_api_client):
mock_api_client.get.return_value = [
{"id": 7, "display_name": "Mario Rossi", "secret": "x"},
]
resp = client.get("/auth/pin-users")
assert resp.status_code == 200
assert resp.get_json() == [{"id": 7, "display_name": "Mario Rossi"}]
def test_pin_users_empty_on_error(self, client, mock_api_client):
mock_api_client.get.return_value = {"error": True, "detail": "boom"}
assert client.get("/auth/pin-users").get_json() == []
def test_pin_users_empty_list_falls_back(self, client, mock_api_client):
mock_api_client.get.return_value = []
assert client.get("/auth/pin-users").get_json() == []
def test_login_pin_success_sets_session(self, client, mock_api_client):
mock_api_client.post.return_value = {"api_key": "pin-key", "user": self.USER}
mock_api_client.get.return_value = {}
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "1234"})
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
assert resp.get_json()["redirect"]
mock_api_client.post.assert_called_once_with(
"/api/auth/login-pin", data={"user_id": 7, "pin": "1234"})
with client.session_transaction() as sess:
assert sess["api_key"] == "pin-key"
assert sess["user_id"] == 7
assert sess.permanent is True
def test_login_pin_wrong_pin_401(self, client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 401, "detail": "x"}
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
assert resp.status_code == 401
assert resp.get_json()["detail"] == "PIN errato"
def test_login_pin_locked_423_shows_detail(self, client, mock_api_client):
mock_api_client.post.return_value = {
"error": True, "status_code": 423, "detail": "Utente bloccato fino alle 10:15"}
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
assert resp.status_code == 423
assert "10:15" in resp.get_json()["detail"]
def test_login_pin_invalid_pin_422(self, client, mock_api_client):
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "12"})
assert resp.status_code == 422
mock_api_client.post.assert_not_called()
@@ -0,0 +1,31 @@
"""Filtro local_dt: il backend parla UTC, la presentazione e' in Europe/Rome (#42)."""
from datetime import datetime, timezone
import pytest
@pytest.fixture
def local_dt(flask_app):
return flask_app.jinja_env.filters["local_dt"]
def test_summer_time(local_dt):
assert local_dt("2026-10-06T08:00:00+00:00") == "06/10/2026 10:00"
def test_winter_time(local_dt):
assert local_dt("2026-01-15T08:00:00+00:00") == "15/01/2026 09:00"
def test_naive_is_utc(local_dt):
assert local_dt("2026-10-06T08:00:00") == "06/10/2026 10:00"
def test_datetime_object_and_format(local_dt):
dt = datetime(2026, 10, 6, 8, 0, tzinfo=timezone.utc)
assert local_dt(dt, "%H:%M") == "10:00"
@pytest.mark.parametrize("empty", [None, ""])
def test_empty(local_dt, empty):
assert local_dt(empty) == "-"
+161
View File
@@ -71,6 +71,61 @@ class TestRecipeNew:
assert resp.status_code == 200
def test_recipe_new_default_interval_present(self, logged_in_client, mock_api_client):
"""#49: default_measurement_interval_minutes pre-fills the loop duration."""
mock_api_client.get.return_value = {"default_measurement_interval_minutes": "45"}
html = logged_in_client.get("/maker/recipes/new").get_data(as_text=True)
assert "measurementIntervalMinutes: 45," in html
def test_recipe_new_default_interval_absent(self, logged_in_client, mock_api_client):
"""#49: key missing, empty or invalid -> unchanged behaviour (null)."""
for payload in ({}, {"default_measurement_interval_minutes": ""},
{"default_measurement_interval_minutes": "abc"},
{"default_measurement_interval_minutes": "5000"}):
mock_api_client.get.return_value = payload
html = logged_in_client.get("/maker/recipes/new").get_data(as_text=True)
assert 'measurementIntervalMinutes: "null",' in html
def test_recipe_edit_ignores_default_interval(self, logged_in_client, mock_api_client):
"""#49: an existing recipe never receives the default."""
def fake_get(path, *a, **k):
if path == "/api/recipes/7":
return {"id": 7, "code": "R", "name": "R"}
if path.endswith("/versions"):
return []
return {"default_measurement_interval_minutes": "45"}
mock_api_client.get.side_effect = fake_get
html = logged_in_client.get("/maker/recipes/7/edit").get_data(as_text=True)
assert 'measurementIntervalMinutes: "null",' in html
class TestRecipeDuplicateProxy:
"""POST /maker/api/recipes/<id>/duplicate (#52)."""
def test_duplicate_proxy_ok(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"id": 9, "code": "A-COPIA"}
resp = logged_in_client.post(
"/maker/api/recipes/3/duplicate", json={"code": "A-COPIA", "name": "A (copia)"}
)
assert resp.status_code == 201
assert resp.get_json()["id"] == 9
mock_api_client.post.assert_called_with(
"/api/recipes/3/duplicate", data={"code": "A-COPIA", "name": "A (copia)"}
)
def test_duplicate_proxy_conflict(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 409, "detail": "exists"}
resp = logged_in_client.post(
"/maker/api/recipes/3/duplicate", json={"code": "A", "name": "A"}
)
assert resp.status_code == 409
def test_recipe_list_has_duplicate_button(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/maker/recipes").get_data(as_text=True)
assert "openDupModal" in html
class TestRoleRequired:
"""Maker role enforcement tests."""
@@ -161,3 +216,109 @@ class TestVersionConflictProxy:
assert resp.status_code == 409
assert resp.get_json()["detail"] == self._CONFLICT["detail"]
class TestVisionRecipeProxy:
"""Proxy ricetta visione (#46): /maker/api/vision/tasks/<id>/recipe."""
def test_upload_forwards_recipe_and_files(self, logged_in_client, mock_api_client):
import io
mock_api_client.post.return_value = {
"task_id": 7, "recipe_dir": "vision/recipes/7/abc", "files": ["a.dxf"],
"outputs": [{"key": "n1.points_in_pct", "quantity": None, "unit": None}],
}
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe",
data={
"recipe": (io.BytesIO(b"{}"), "r.json"),
"files": [(io.BytesIO(b"dxf"), "a.dxf"), (io.BytesIO(b"npz"), "m.npz")],
},
content_type="multipart/form-data",
)
assert resp.status_code == 200
assert resp.get_json()["outputs"][0]["key"] == "n1.points_in_pct"
endpoint = mock_api_client.post.call_args.args[0]
sent = mock_api_client.post.call_args.kwargs["files"]
assert endpoint == "/api/vision/tasks/7/recipe"
# un campo `recipe` e un campo `files` per ogni allegato
assert [name for name, _f in sent] == ["recipe", "files", "files"]
def test_upload_without_recipe_is_400(self, logged_in_client, mock_api_client):
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe", data={}, content_type="multipart/form-data"
)
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_upload_proxies_422(self, logged_in_client, mock_api_client):
import io
mock_api_client.post.return_value = {
"error": True, "status_code": 422, "detail": "file mancante: a.dxf",
}
resp = logged_in_client.post(
"/maker/api/vision/tasks/7/recipe",
data={"recipe": (io.BytesIO(b"{}"), "r.json")},
content_type="multipart/form-data",
)
assert resp.status_code == 422
assert "a.dxf" in resp.get_json()["detail"]
def test_get_recipe_proxies(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {
"task_id": 7, "recipe_dir": None, "recipe": None, "files": [], "outputs": [],
}
resp = logged_in_client.get("/maker/api/vision/tasks/7/recipe")
assert resp.status_code == 200
mock_api_client.get.assert_called_once_with("/api/vision/tasks/7/recipe")
class TestTaskEditorVisionUi:
"""task_editor.html espone i tipi visione e la select vision_output (#46)."""
def test_editor_has_vision_types_and_section(self, logged_in_client, mock_api_client):
from tests.conftest import api_get_router
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": [{
"id": 11, "title": "T", "task_type": "dxf_compare", "subtasks": [],
"file_path": None, "order_index": 1,
}],
})
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
assert 'value="camera_measure"' in html
assert 'value="dxf_compare"' in html
assert "visionRecipe(" in html
assert "vision_output" in html
assert "js/vision_recipe.js" in html
class TestImageCropInclusion:
"""#56: i due editor includono image-crop.js e agganciano il ritaglio all'upload."""
def test_task_editor_includes_crop(self, logged_in_client, mock_api_client):
from tests.conftest import api_get_router
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": [{
"id": 11, "title": "T", "task_type": "measure", "subtasks": [],
"file_path": None, "order_index": 1,
}],
})
html = logged_in_client.get("/maker/recipes/3/tasks").get_data(as_text=True)
assert "js/image-crop.js" in html
assert "tmfCropBeforeUpload(file)" in html
def test_recipe_editor_includes_crop(self, logged_in_client, mock_api_client):
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Recipe A"},
[{"version_number": 1, "is_current": True}],
]
html = logged_in_client.get("/maker/recipes/1/edit").get_data(as_text=True)
assert "js/image-crop.js" in html
assert "tmfCropBeforeUpload(file)" in html

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