Compare commits

..

38 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 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 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
86 changed files with 5794 additions and 521 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
+3
View File
@@ -71,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"
+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)
+38 -1
View File
@@ -1,5 +1,6 @@
"""Authentication router - login, logout, profile."""
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,6 +40,35 @@ 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,
+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
+1
View File
@@ -147,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,
+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),
+4
View File
@@ -23,6 +23,7 @@ async def download_spc_report(
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(
@@ -57,6 +59,7 @@ async def download_measurement_report(
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(
+60 -1
View File
@@ -3,12 +3,13 @@ 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
@@ -38,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.
@@ -67,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)
@@ -106,6 +111,7 @@ async def get_summary(
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:
@@ -113,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)
@@ -128,6 +135,7 @@ async def get_capability(
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:
@@ -135,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)
@@ -151,6 +160,7 @@ async def get_control_chart(
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:
@@ -158,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]
@@ -178,6 +189,7 @@ async def get_histogram(
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),
@@ -186,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)
@@ -235,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():
+32 -2
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, revoke_all_keys
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"])
@@ -151,6 +157,30 @@ async def change_user_password(
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,
+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,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')
+16 -3
View File
@@ -2,7 +2,7 @@
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):
@@ -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):
+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
+18 -3
View File
@@ -2,7 +2,7 @@
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):
@@ -55,6 +55,8 @@ class ProductionRunResponse(BaseModel):
# 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
+6
View File
@@ -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)
+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
+20
View File
@@ -50,6 +50,8 @@ class UserResponse(BaseModel):
active: bool
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}$")
+2
View File
@@ -3,6 +3,7 @@ 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
@@ -17,6 +18,7 @@ __all__ = [
"RecipeVersion",
"RecipeTask",
"RecipeSubtask",
"TaskResult",
"Measurement",
"AccessLog",
"SystemSetting",
+23 -2
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,12 +15,22 @@ 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".
# 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):
__tablename__ = "recipe_tasks"
@@ -55,6 +65,17 @@ class RecipeTask(Base):
# 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
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"
@@ -39,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 [])
+83 -2
View File
@@ -54,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
@@ -65,7 +137,16 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
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,
+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
+15 -1
View File
@@ -145,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)
)
+76
View File
@@ -117,6 +117,10 @@ async def _copy_tasks_to_version(
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
@@ -255,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,
+12
View File
@@ -48,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 = []
@@ -73,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)
@@ -281,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.
@@ -290,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
@@ -336,6 +342,8 @@ async def generate_spc_report(
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")
@@ -367,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
@@ -427,6 +437,8 @@ async def generate_measurement_report(
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")
+84
View File
@@ -149,3 +149,87 @@ class TestVisionRecipeDirMigration:
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
+2
View File
@@ -74,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():
+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 != "":
@@ -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">
@@ -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,6 +344,7 @@ function spcDashboard() {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
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());
@@ -417,6 +450,7 @@ function spcDashboard() {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
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());
@@ -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()
+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
@@ -0,0 +1,164 @@
"""#47/#48: liste Tipo/Unita e tolleranze generali dalle Impostazioni nel task editor."""
import json
import os
import re
import shutil
import subprocess
import tempfile
import pytest
from tests.conftest import api_get_router
_RECIPE_ROUTES = {
"/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,
}],
}
def _render(logged_in_client, mock_api_client, settings):
mock_api_client.get.side_effect = api_get_router({**_RECIPE_ROUTES, "/api/settings": settings})
resp = logged_in_client.get("/maker/recipes/3/tasks")
assert resp.status_code == 200
return resp.get_data(as_text=True)
class TestTaskEditorSettings:
def test_configured_lists_reach_template(self, logged_in_client, mock_api_client):
html = _render(logged_in_client, mock_api_client, {
"list_measurement_types": json.dumps(["Planarita", "Concentricita"]),
"list_units": json.dumps(["mm", "thou"]),
})
assert 'configuredTypes: ["Planarita", "Concentricita"]' in html
assert 'configuredUnits: ["mm", "thou"]' in html
# le select leggono le liste, non hanno piu' le option scritte a mano
assert 'x-for="o in measurementTypeOptions()"' in html
assert 'x-for="o in unitOptions()"' in html
assert '<option value="diameter">' not in html
def test_missing_keys_keep_hand_written_values(self, logged_in_client, mock_api_client):
for settings in ({}, {"list_measurement_types": "", "list_units": "non json"},
{"list_units": json.dumps({"a": 1})}, {"list_units": "[]"}):
html = _render(logged_in_client, mock_api_client, settings)
assert "configuredTypes: null," in html
assert "configuredUnits: null," in html
# le voci di oggi restano come default
assert "value: 'diameter'" in html
assert "defaultUnits: ['mm', 'cm'" in html
def test_settings_error_keeps_defaults(self, logged_in_client, mock_api_client):
html = _render(logged_in_client, mock_api_client, {"error": True, "detail": "x"})
assert "configuredTypes: null," in html
assert "generalTolPlus: null," in html
def test_general_tolerances_reach_template(self, logged_in_client, mock_api_client):
html = _render(logged_in_client, mock_api_client, {
"general_tolerance_plus": "0.1", "general_tolerance_minus": "0.25",
})
assert "generalTolPlus: 0.1," in html
assert "generalTolMinus: 0.25," in html
def test_invalid_tolerances_are_none(self, logged_in_client, mock_api_client):
for plus, minus in (("", "abc"), ("-1", "nan"), ("inf", None)):
settings = {"general_tolerance_plus": plus}
if minus is not None:
settings["general_tolerance_minus"] = minus
html = _render(logged_in_client, mock_api_client, settings)
assert "generalTolPlus: null," in html
assert "generalTolMinus: null," in html
# ---- comportamento JS (node): opzioni fuori lista e automatismo delle tolleranze ----
_NODE = shutil.which("node")
def _run_js(html, body):
"""Esegue taskEditor() estratto dalla pagina e poi `body` (che usa `ed`); stampa JSON."""
scripts = re.findall(r"<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>", html, re.S | re.I)
src = next(s for s in scripts if "function taskEditor()" in s)
program = src + "\nconst ed = taskEditor();\nconst out = (function(){" + body + "})();\nconsole.log(JSON.stringify(out));"
# su file: la riga di comando di Windows non regge lo script intero
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as fh:
fh.write(program)
try:
res = subprocess.run([_NODE, fh.name], capture_output=True, text=True,
encoding="utf-8", timeout=30)
finally:
os.unlink(fh.name)
assert res.returncode == 0, res.stderr
return json.loads(res.stdout)
@pytest.mark.skipif(_NODE is None, reason="node non disponibile")
class TestTaskEditorJs:
def test_out_of_list_value_stays_selectable(self, logged_in_client, mock_api_client):
html = _render(logged_in_client, mock_api_client, {
"list_measurement_types": json.dumps(["Planarita"]),
"list_units": json.dumps(["thou"]),
})
out = _run_js(html, """
ed.newSubtask.measurement_type = 'Vecchio'; ed.newSubtask.unit = 'mm';
return {t: ed.measurementTypeOptions().map(o => o.value),
u: ed.unitOptions().map(o => o.value), d: ed.defaultUnit()};
""")
assert out["t"] == ["Planarita", "Vecchio"]
assert out["u"] == ["thou", "mm"]
assert out["d"] == "thou"
def test_default_lists_when_not_configured(self, logged_in_client, mock_api_client):
html = _render(logged_in_client, mock_api_client, {})
out = _run_js(html, """
return {t: ed.measurementTypeOptions().map(o => o.value),
u: ed.unitOptions().map(o => o.value), d: ed.defaultUnit()};
""")
assert out["t"][0] == "linear" and out["t"][-1] == "other"
assert out["u"][0] == "mm" and "Nm" in out["u"]
assert out["d"] == "mm"
def test_tolerance_autofill(self, logged_in_client, mock_api_client):
html = _render(logged_in_client, mock_api_client, {
"general_tolerance_plus": "0.1", "general_tolerance_minus": "0.2",
})
out = _run_js(html, """
ed.tasks = [{id: 1, subtasks: []}];
ed.resetNewSubtask(1);
const r = {};
ed.newSubtask.nominal = 10; ed.onNominalInput({target: {value: '10'}});
r.first = [ed.newSubtask.utl, ed.newSubtask.ltl];
// cambia ancora il nominale prima di toccare i limiti: ricalcola
ed.onNominalInput({target: {value: '12.5'}});
r.second = [ed.newSubtask.utl, ed.newSubtask.ltl];
// modifica a mano utl: smette di riempire
ed.newSubtask.utl = 99; ed.tolAuto = false;
ed.onNominalInput({target: {value: '20'}});
r.third = [ed.newSubtask.utl, ed.newSubtask.ltl];
r.text = ed.generalTolText();
return r;
""")
assert out["first"] == [10.1, 9.8]
assert out["second"] == [12.6, 12.3]
assert out["third"] == [99, 12.3]
assert "+0.1 / −0.2" in out["text"]
def test_no_autofill_without_tolerances_or_when_editing(self, logged_in_client, mock_api_client):
html = _render(logged_in_client, mock_api_client, {})
out = _run_js(html, """
ed.tasks = [{id: 1, subtasks: []}];
ed.resetNewSubtask(1);
ed.onNominalInput({target: {value: '10'}});
return {utl: ed.newSubtask.utl, ltl: ed.newSubtask.ltl, auto: ed.tolAuto,
text: ed.generalTolText()};
""")
assert out == {"utl": None, "ltl": None, "auto": False, "text": ""}
html = _render(logged_in_client, mock_api_client, {"general_tolerance_plus": "0.1"})
out = _run_js(html, """
ed.startEditSubtask({id: 5, marker_number: 1, nominal: 10, utl: null, ltl: null}, 1);
ed.onNominalInput({target: {value: '10'}});
return {utl: ed.newSubtask.utl, auto: ed.tolAuto};
""")
assert out == {"utl": None, "auto": False}
@@ -0,0 +1,79 @@
"""Editor task del Maker: tipo check, in_loop, at_close, campi della nota (#53 #54 #55)."""
import re
from tests.conftest import api_get_router
def _editor_html(logged_in_client, mock_api_client, tasks):
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": tasks,
})
resp = logged_in_client.get("/maker/recipes/3/tasks")
assert resp.status_code == 200
return resp.get_data(as_text=True)
class TestTaskEditorFields:
def test_check_option_in_both_selects(self, logged_in_client, mock_api_client):
"""#54: il tipo check e presente nella select di creazione e in quella di modifica."""
html = _editor_html(logged_in_client, mock_api_client, [{
"id": 11, "title": "T", "task_type": "note", "subtasks": [],
"file_path": None, "order_index": 1,
}])
assert html.count('<option value="check">') == 2
assert "Controllo OK/NOK" in html
def test_loop_and_note_controls_present(self, logged_in_client, mock_api_client):
"""#53 #55: checkbox del ciclo, a fine produzione e editor dei campi in entrambi i form."""
html = _editor_html(logged_in_client, mock_api_client, [{
"id": 11, "title": "T", "task_type": "note", "subtasks": [],
"file_path": None, "order_index": 1, "in_loop": True,
}])
assert "Nel ciclo di misura (timer e cicalino)" in html
assert "Compila a fine produzione" in html
assert 'x-model="newTask.in_loop"' in html
assert 'x-model="editTaskData.in_loop"' in html
assert "addNoteField(newTask)" in html
assert "addNoteField(editTaskData)" in html
# segno sui task nel ciclo, e niente quote per check
assert 'x-show="task.in_loop"' in html
assert "task.task_type !== 'check'" in html
def test_save_bodies_carry_new_fields(self, logged_in_client, mock_api_client):
"""Creazione e modifica inviano in_loop, at_close e fields nel corpo."""
html = _editor_html(logged_in_client, mock_api_client, [])
assert len(re.findall(r"in_loop: !!this\.(newTask|editTaskData)\.in_loop", html)) == 2
assert len(re.findall(r"fields: fp\.fields", html)) == 2
assert len(re.findall(r"at_close: this\.(newTask|editTaskData)\.task_type === 'note'", html)) == 2
class TestTaskProxyForwardsFields:
"""I proxy inoltrano il corpo cosi com'e: in_loop/at_close/fields arrivano al backend."""
BODY = {
"title": "Note finali", "task_type": "note", "in_loop": False, "at_close": True,
"fields": [{"key": "operatore", "label": "Operatore", "type": "text",
"default": None, "attachment": False}],
}
def test_create_forwards(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"id": 5, **self.BODY}
resp = logged_in_client.post("/maker/api/recipes/3/tasks", json=self.BODY)
assert resp.status_code == 201
mock_api_client.post.assert_called_once_with("/api/recipes/3/tasks", data=self.BODY)
def test_update_forwards(self, logged_in_client, mock_api_client):
mock_api_client.put.return_value = {"id": 5, **self.BODY}
resp = logged_in_client.put("/maker/api/tasks/5", json=self.BODY)
assert resp.status_code == 200
mock_api_client.put.assert_called_once_with("/api/tasks/5", data=self.BODY)
def test_update_422_detail_returned(self, logged_in_client, mock_api_client):
"""Un 422 del backend arriva al browser con il suo detail e lo status."""
mock_api_client.put.return_value = {
"error": True, "status_code": 422, "detail": "Massimo 20 campi",
}
resp = logged_in_client.put("/maker/api/tasks/5", json=self.BODY)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "Massimo 20 campi"
@@ -0,0 +1,86 @@
"""gitea:Adriano/TieMeasureFlow#46 - operator side of the vision tasks.
The backend of contract B is not merged yet: api_client is mocked.
"""
import io
from unittest.mock import MagicMock, patch
from tests.conftest import api_get_router
TASK = {
"id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure",
"title": "Foto", "directive": None, "description": None, "file_path": None,
"file_type": None, "annotations_json": None, "subtasks": [],
}
RECIPE = {
"id": 3, "code": "REC-1", "name": "Ricetta",
"measurement_interval_minutes": 30, "allow_manual_input": True,
"current_version": {"id": 5, "version_number": 1},
}
def test_execute_page_has_acquire_input(logged_in_client, mock_api_client, monkeypatch):
"""camera_measure screen offers the file/camera input and the acquire entry point."""
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/12": TASK,
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
"/api/recipes/3": RECIPE,
})
html = logged_in_client.get("/measure/execute/12").get_data(as_text=True)
assert 'capture="environment"' in html
assert 'accept="image/*"' in html
assert "visionExecute()" in html
assert "/measure/api/vision/execute" in html
def test_vision_execute_proxy_forwards_multipart(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {
"vision_result_id": 7, "outcome": "ok", "overlay": [], "failures": [],
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
}
resp = logged_in_client.post(
"/measure/api/vision/execute",
data={
"task_id": "12", "lot_number": "L1", "serial_number": "S1",
"production_run_id": "4",
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
},
content_type="multipart/form-data",
)
assert resp.status_code == 200
assert resp.get_json()["vision_result_id"] == 7
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/execute"
assert kwargs["data"]["task_id"] == "12"
assert kwargs["data"]["lot_number"] == "L1"
assert kwargs["data"]["production_run_id"] == "4"
name, content, mime = kwargs["files"]["image"]
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
def test_vision_execute_proxy_requires_image(logged_in_client, mock_api_client):
resp = logged_in_client.post("/measure/api/vision/execute", data={"task_id": "12"})
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_vision_execute_proxy_relays_backend_error(logged_in_client, mock_api_client):
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "bad"}
resp = logged_in_client.post(
"/measure/api/vision/execute",
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
content_type="multipart/form-data",
)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "bad"
def test_vision_result_image_proxy(logged_in_client):
fake = MagicMock(status_code=200, content=b"IMG", headers={"content-type": "image/jpeg"})
with patch("blueprints.measure.http_requests.get", return_value=fake) as g:
resp = logged_in_client.get("/measure/api/vision/results/7/image")
assert resp.status_code == 200 and resp.data == b"IMG"
assert g.call_args[0][0].endswith("/api/vision/results/7/image")
assert g.call_args[1]["headers"]["X-API-Key"] == "test-api-key-12345"
@@ -0,0 +1,46 @@
"""Live station monitor page and JSON proxy (#57)."""
from unittest.mock import patch
import pytest
@pytest.fixture
def mock_api():
with patch("blueprints.monitor.api_client") as m:
yield m
def _login(client, roles, is_admin=False):
with client.session_transaction() as sess:
sess["api_key"] = "k"
sess["user"] = {"id": 2, "username": "u", "display_name": "U", "roles": roles,
"is_admin": is_admin, "language_pref": "en", "theme_pref": "light",
"active": True}
sess["language"] = "en"
def test_page_ok_for_supervisor(client, mock_api):
_login(client, ["Supervisor"])
resp = client.get("/monitor")
assert resp.status_code == 200 and b"stationMonitor" in resp.data
def test_page_forbidden_for_operator(client, mock_api):
_login(client, ["MeasurementTec"])
assert client.get("/monitor").status_code == 403
assert client.get("/monitor/api/runs").status_code == 403
def test_proxy_returns_backend_json(client, mock_api):
_login(client, ["Supervisor"])
mock_api.get.return_value = [{"id": 1, "station_code": "ST-1"}]
resp = client.get("/monitor/api/runs")
assert resp.status_code == 200 and resp.get_json()[0]["station_code"] == "ST-1"
mock_api.get.assert_called_once_with("/api/production-runs/open")
def test_proxy_passes_backend_error(client, mock_api):
_login(client, [], is_admin=True)
mock_api.get.return_value = {"error": True, "status_code": 403, "detail": "no"}
resp = client.get("/monitor/api/runs")
assert resp.status_code == 403 and resp.get_json()["error"] is True
@@ -104,7 +104,7 @@ def test_the_selection_screen_starts_the_sequence(
def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client):
html = _list_page(logged_in_client, mock_api_client)
assert "/measure/start/3" in html
assert "AVVIA IN SEQUENZA" in html
assert "AVVIA ATTIVITÀ" in html
# ---------------------------------------------------------------------------
@@ -163,6 +163,32 @@ def test_the_header_offers_completato_not_the_summary(
assert '@click="goToNextTask()"' in html
def test_only_one_completato_and_it_is_big(
logged_in_client, mock_api_client, monkeypatch,
):
"""#60: «Completato» una sola volta (in basso), largo e alto per il touch."""
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
# the label as a button text on its own line (comments in the JS also name it)
assert [l.strip() for l in html.splitlines()].count("Completato") == 1
assert "min-h-[64px]" in html
def test_no_open_run_means_no_fermo_linea_and_no_fake_timer(
logged_in_client, mock_api_client, monkeypatch,
):
"""#61: senza produzione aperta i pulsanti non si vedono, la modale non si
apre e un rifiuto del server non avvia un timer locale finto."""
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
assert 'x-if="isMeasureTask && productionRun"' in html
assert "!this.productionRun" in html
# #61: loadProductionRun mostra il detail del server, non solo il messaggio generico
assert "errBody.detail" in html
assert "Nessuna produzione aperta su questa stazione" in html
# il vecchio ripiego locale non c'e' piu'
assert "this.startCountdownFrom(this.measurementIntervalMinutes * 60)" not in html.split(
"async startProductionFromOverlay()")[1].split("async confirmCycle")[0]
def test_fine_ciclo_is_shown_before_it_can_be_used(
logged_in_client, mock_api_client, monkeypatch,
):
@@ -196,3 +222,27 @@ def test_the_operator_flow_never_links_to_the_statistics(
]
for html in pages:
assert "/statistics" not in html
def test_trace_panel_only_for_recipes_that_require_it(
logged_in_client, mock_api_client, monkeypatch,
):
"""#63/#64: ricerca+griglia in alto, pannello lotto/seriale solo dopo la
scelta di una ricetta che lo richiede, con i soli campi richiesti."""
_italian(logged_in_client)
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "A", "name": "Con lotto", "requires_lot": True,
"requires_serial": False},
{"id": 2, "code": "B", "name": "Libera"},
]
html = logged_in_client.get("/measure/select").get_data(as_text=True)
assert 'id="trace-panel"' in html
assert 'x-show="selected && needsTrace(selected)"' in html
assert 'x-show="selected && selected.requires_lot"' in html
assert 'x-show="selected && selected.requires_serial"' in html
# la ricerca e la griglia precedono il pannello
assert html.index("Cerca ricetta") < html.index('id="trace-panel"')
assert html.index("Recipe Grid") < html.index('id="trace-panel"')
# le ricette senza obblighi partono direttamente
assert 'x-if="!needsTrace(recipe)"' in html
@@ -112,3 +112,21 @@ class TestSPCProxy:
assert resp.status_code == 200
data = resp.get_json()
assert "cp" in data
class TestRunsProxy:
"""#58: filtro per sessione di produzione."""
def test_runs_proxy(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = [{"id": 5, "measurement_count": 3}]
resp = logged_in_client.get("/statistics/api/runs?recipe_id=7")
assert resp.status_code == 200
assert resp.get_json()[0]["id"] == 5
args, kwargs = mock_api_client.get.call_args
assert args[0] == "/api/statistics/runs"
assert kwargs["params"] == {"recipe_id": "7"}
def test_summary_forwards_production_run_id(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"total": 1}
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
@@ -256,7 +256,7 @@ def test_admin_stations_inline_js_is_valid_in_english(
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
"""Same guard for /admin/users (reuses the userManagement Alpine pattern)."""
"""Same guard for the Users tab, now inside /admin/settings (#50)."""
mock_admin_api.get.return_value = [{
"id": 1, "username": "admin", "display_name": "Admin",
"email": None, "roles": ["Maker"], "is_admin": True,
@@ -264,7 +264,7 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
}]
_force_italian(logged_in_client)
resp = logged_in_client.get("/admin/users")
resp = logged_in_client.get("/admin/settings")
assert resp.status_code == 200
html = resp.data.decode("utf-8")
@@ -272,9 +272,9 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
assert scripts
for i, body in enumerate(scripts):
_node_check(body, f"/admin/users script[{i}]")
_node_check(body, f"/admin/settings script[{i}]")
_check_alpine_attributes(html, "/admin/users")
_check_alpine_attributes(html, "/admin/settings")
@pytest.fixture
@@ -1862,8 +1862,8 @@ msgstr "No description available"
#: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107
msgid "AVVIA IN SEQUENZA"
msgstr "START THE SEQUENCE"
msgid "AVVIA ATTIVITÀ"
msgstr "START ACTIVITY"
# Maker - Task Editor
#: templates/measure/select_recipe.html:302
@@ -1864,8 +1864,8 @@ msgstr "Nessuna descrizione disponibile"
#: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107
msgid "AVVIA IN SEQUENZA"
msgstr "AVVIA IN SEQUENZA"
msgid "AVVIA ATTIVITÀ"
msgstr "AVVIA ATTIVITÀ"
# Maker - Task Editor
#: templates/measure/select_recipe.html:302
@@ -1825,7 +1825,7 @@ msgstr ""
#: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107
msgid "AVVIA IN SEQUENZA"
msgid "AVVIA ATTIVITÀ"
msgstr ""
#: templates/measure/select_recipe.html:302
+117 -22
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import math
import os
import subprocess
import tempfile
import time
from dataclasses import dataclass, field
from functools import lru_cache
@@ -18,6 +19,9 @@ from typing import Any
import numpy as np
from visionsuite.core.image import Image
from visionsuite.task import Task
from visionsuite.task.graph import RecipeError # #46: errori di ricetta v2
from visionsuite.task.registry import load_tools
from visionsuite.task.session import Session
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
@@ -49,6 +53,10 @@ class RunOutcome:
# Kept apart from `failures`: the tool did not raise, it just produced a
# number the caller must not measure a part by. Value is the reason.
invalid_outputs: dict[str, str] = field(default_factory=dict)
# #46: contorni di tutti gli strumenti (px immagine, con tool_id) e esito
# del TaskReport (informativo: il verdetto quote lo dà il backend).
overlay: list[dict] = field(default_factory=list)
outcome: str | None = None
@lru_cache(maxsize=1)
@@ -97,12 +105,46 @@ def engine_version() -> str:
return _engine_version_from_git()
def _scale_calibration(calibration: dict | None):
"""Dict {"mm_per_px"|"px_per_mm"} -> ScaleCalibration (gli strumenti di
quota convertono con `in_millimetres`, che non accetta un dict)."""
if calibration is None:
return None
from visionsuite.calibration import ScaleCalibration
if not isinstance(calibration, dict) or not (
calibration.get("mm_per_px") or calibration.get("px_per_mm")
):
raise GraphBuildError(
"calibration must be {'mm_per_px': float} or {'px_per_mm': float}"
)
key = "mm_per_px" if calibration.get("mm_per_px") else "px_per_mm"
return ScaleCalibration(**{key: float(calibration[key])})
def _open_session(graph: dict, recipe_dir: Any, calibration: dict | None,
workdir: str) -> Session:
"""Costruisce la Session v2; RecipeError e grafo malformato -> GraphBuildError."""
cal = _scale_calibration(calibration)
try:
return Session.from_dict(
graph, Path(recipe_dir) if recipe_dir else Path(workdir), cal,
)
except RecipeError as exc:
raise GraphBuildError(f"malformed graph: {exc}") from exc
except (KeyError, TypeError, AttributeError, ValueError) as exc:
raise GraphBuildError(
f"malformed graph: {type(exc).__name__}: {exc}"
) from exc
def run_graph(
image: Any,
graph: dict,
calibration: dict | None = None,
recipe_dir: Any = None,
) -> RunOutcome:
"""Run a serialised vs-task graph against one image.
"""Run a serialised vs-task v2 graph against one image.
A tool that fails does not stop the others - on a crooked piece an edge may
simply not be there, and the other quotes are still wanted. Failures come
@@ -112,33 +154,35 @@ def run_graph(
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
actually read via `.pixels`; an `Image` passed in is used as-is.
Two phases, on purpose: building the task from `graph` can fail on
nothing but the caller's input (a bad schema version, a tool with no
"kind", a graph that is not a dict at all) - that is `GraphBuildError`,
a 422 for the worker. Once the task exists, `task.run()` is the engine's
own business; an exception from there is not the caller's mistake, and
the worker must not call it one.
`recipe_dir` is the folder of the recipe's files (meta.dxf, meta.models,
...); without it an empty temporary folder is used, so a recipe that needs
a file fails as a malformed graph instead of reading from the CWD.
Two phases, on purpose: building the session can fail on nothing but the
caller's input (a bad schema version, a tool with no "kind", a missing
file) - that is `GraphBuildError`, a 422 for the worker. Once it exists,
running is the engine's own business; an exception from there is not the
caller's mistake, and the worker must not call it one.
"""
try:
task = Task.from_dict(graph)
except (KeyError, TypeError, AttributeError, ValueError) as exc:
raise GraphBuildError(
f"malformed graph: {type(exc).__name__}: {exc}"
) from exc
wrapped_image = image if isinstance(image, Image) else Image(pixels=np.asarray(image))
context: dict[str, Any] = {"image": wrapped_image}
if calibration is not None:
context["calibration"] = calibration
started = time.monotonic()
report = task.run(context=context)
elapsed_ms = int((time.monotonic() - started) * 1000)
load_tools() # #46: registra gli strumenti degli entry point di vs-tools
with tempfile.TemporaryDirectory() as empty_dir:
session = _open_session(graph, recipe_dir, calibration, empty_dir)
wrapped_image = (
image if isinstance(image, Image) else Image(pixels=np.asarray(image))
)
started = time.monotonic()
report = session.run(wrapped_image)
elapsed_ms = int((time.monotonic() - started) * 1000)
outputs: dict[str, float] = {}
failures: list[ToolFailure] = []
invalid_outputs: dict[str, str] = {}
overlay: list[dict] = []
for result in report:
# #46: overlay di ogni strumento, con il tool_id di chi lo ha prodotto
for item in result.overlay or []:
if isinstance(item, dict):
overlay.append({**item, "tool_id": result.tool_id})
if not result.ok:
failures.append(ToolFailure(
tool_id=result.tool_id,
@@ -164,4 +208,55 @@ def run_graph(
engine_version=engine_version(),
duration_ms=elapsed_ms,
invalid_outputs=invalid_outputs,
overlay=overlay,
outcome=report.outcome,
)
def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def validate_graph(
graph: dict, recipe_dir: Any = None
) -> tuple[bool, list[str], list[dict]]:
"""Validate a v2 recipe without an image: (ok, errors, numeric outputs).
Not a draft: a missing meta file is an error. `outputs` lists the numeric
outputs the tools declare (OUT properties with a numeric default, plus the
`values` of dimension tools), with quantity/unit when declared.
"""
load_tools()
if not isinstance(graph, dict):
return False, ["graph must be a JSON object"], []
try:
task = Task.from_dict(
graph, recipe_dir=Path(recipe_dir) if recipe_dir else None
)
except RecipeError as exc:
return False, list(exc.errors), []
except (KeyError, TypeError, AttributeError, ValueError) as exc:
return False, [f"{type(exc).__name__}: {exc}"], []
outputs: list[dict] = []
for tool in task.tools:
value_quantities = getattr(tool, "value_quantities", None) or {}
values = tuple(getattr(tool, "values", ()) or ())
for prop in tool.outputs():
if not (_is_number(prop.default) or prop.name in values):
continue
quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None))
if not isinstance(quantity, str):
quantity = None
unit = None
if quantity == "length":
nominal_unit = getattr(tool, "nominal_unit", None)
unit = nominal_unit if isinstance(nominal_unit, str) else None
elif quantity == "angle":
unit = "deg"
outputs.append({
"key": f"{tool.id}.{prop.name}",
"quantity": quantity,
"unit": unit,
})
return True, [], outputs
+146 -13
View File
@@ -34,14 +34,16 @@ def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
def a_circle_graph() -> dict:
"""A two-tool graph, built with vs-task's own API."""
from visionsuite.task import Task
from visionsuite.task.tools import DetectCircleTool, DiameterTool
from visionsuite.tools.detect import DetectCircle
from visionsuite.tools.dimensions import Diameter
task = Task(name="prova")
found = task.add(DetectCircleTool(name="disco"))
found = task.add(DetectCircle(name="disco"))
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
found.scan_size = 30.0
quota = task.add(DiameterTool(name="diametro"))
quota = task.add(Diameter(name="diametro"))
quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px
quota.link("circle", found, "circle")
return task.to_dict()
@@ -63,7 +65,7 @@ def test_a_broken_tool_does_not_sink_the_others():
graph = a_circle_graph()
graph["tools"].append(
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
"properties": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
"values": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
)
outcome = run_graph(a_disc(), graph)
@@ -81,14 +83,21 @@ class _FakeResult:
self.ok = ok
self.outputs = outputs or {}
self.error = error
self.overlay = [] # #46: v2, ogni risultato porta l'overlay
class _FakeReport(list):
"""#46: run_graph ora legge `.outcome` dal report di Session.run."""
outcome = "pass"
def test_a_nan_output_is_dropped_not_measured(monkeypatch):
"""vs-measure emits NaN on an empty slice (caliper.py:112 per the
revisione) - a NaN must not become a quote's value (#16)."""
def fake_run(self, context=None):
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})]
monkeypatch.setattr(runner.Task, "run", fake_run)
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})])
monkeypatch.setattr(runner.Session, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -97,9 +106,9 @@ def test_a_nan_output_is_dropped_not_measured(monkeypatch):
def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
def fake_run(self, context=None):
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})]
monkeypatch.setattr(runner.Task, "run", fake_run)
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})])
monkeypatch.setattr(runner.Session, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -109,9 +118,9 @@ def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
def test_a_bool_output_is_not_treated_as_a_measurable_number(monkeypatch):
"""bool is a subclass of int - a flag output must not become a quote."""
def fake_run(self, context=None):
return [_FakeResult("t1", "flag", True, outputs={"present": True})]
monkeypatch.setattr(runner.Task, "run", fake_run)
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "flag", True, outputs={"present": True})])
monkeypatch.setattr(runner.Session, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -155,3 +164,127 @@ def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
engine_version()
finally:
runner._engine_version_from_git.cache_clear()
# ---------------------------------------------------------------------------
# #46: ricette v2 complete - overlay, calibrazione, recipe_dir con DXF.
# ---------------------------------------------------------------------------
import hashlib
import shutil
from pathlib import Path
import cv2
_VS = Path(__file__).resolve().parents[3] / "vendor" / "visionsuite"
_SAMPLE_DXF = _VS / "packages" / "vs-dxf" / "tests" / "fixtures" / "sample.dxf"
# Il disegno di prova: rettangolo 200x100 mm con foro r=10 mm.
_PX_PER_MM = 4.0
def _dxf_scene() -> np.ndarray:
"""Il pezzo del disegno rasterizzato a 4 px/mm, margine 60 mm."""
margin = 60.0
w = int((200.0 + 2 * margin) * _PX_PER_MM)
h = int((100.0 + 2 * margin) * _PX_PER_MM)
img = np.full((h, w), 255, dtype=np.uint8)
cx, cy = w // 2, h // 2
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
cv2.rectangle(img, (int(cx - hw), int(cy - hh)), (int(cx + hw), int(cy + hh)), 0, -1)
cv2.circle(img, (cx, cy), int(10.0 * _PX_PER_MM), 255, -1)
return img
def _dxf_recipe() -> dict:
"""CompareDxf con ancora a due punti, collegati a due DetectPointScan.
Le due scansioni trovano i bordi sinistro e destro del pezzo una riga
sopra il lato in basso: origine del disegno = angolo in basso a sinistra,
direzione a -> b = asse x del disegno.
"""
h, w = _dxf_scene().shape
cx, cy = w // 2, h // 2
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
row = cy + hh - 1.0
def scan(tid: str, col: float) -> dict:
return {"id": tid, "name": tid, "kind": "DetectPointScan",
"values": {"start_row": row, "start_col": col - 30.0,
"end_row": row, "end_col": col + 30.0,
"width": 5.0, "select": "first"},
"links": {}}
return {
"schema_version": 2,
"name": "dxf",
"meta": {"dxf": {"disegno": { # sha256 obbligatorio in meta (v2)
"path": "disegno.dxf",
"sha256": hashlib.sha256(_SAMPLE_DXF.read_bytes()).hexdigest()}}},
"tools": [
scan("t-a", cx - hw),
scan("t-b", cx + hw),
{"id": "t-dxf", "name": "confronto", "kind": "CompareDxf",
"values": {"dxf": "disegno", "mm_per_px": 1.0 / _PX_PER_MM,
"anchor": "two_points",
"band_plus": 8.0, "band_minus": 8.0},
"links": {"point_a": {"tool": "t-a", "property": "point"},
"point_b": {"tool": "t-b", "property": "point"}}},
],
}
def test_a_circle_recipe_returns_overlay_and_outcome():
outcome = run_graph(a_disc(), a_circle_graph())
assert outcome.outcome in ("pass", "warning", "fail")
assert outcome.overlay, "DetectCircle must draw something"
assert all("tool_id" in item for item in outcome.overlay)
def test_calibration_dict_gives_mm_quotes():
graph = a_circle_graph()
for tool in graph["tools"]:
if tool["kind"] == "Diameter":
tool["values"]["nominal_unit"] = "mm"
outcome = run_graph(a_disc(), graph, calibration={"mm_per_px": 0.5})
assert outcome.failures == []
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".value"))
assert diameter == pytest.approx(60.0, abs=2.0) # 120 px * 0.5 mm/px
def test_compare_dxf_two_points_with_recipe_dir(tmp_path):
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
outcome = run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
assert outcome.failures == []
assert outcome.outputs["t-dxf.n_points"] > 1000
assert outcome.outputs["t-dxf.points_in_pct"] > 95.0
roles = {item.get("role") for item in outcome.overlay}
assert {"reference", "found"} <= roles
assert "t-dxf" in {item["tool_id"] for item in outcome.overlay}
def test_missing_meta_file_is_a_graph_build_error(tmp_path):
with pytest.raises(runner.GraphBuildError):
run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
def test_validate_lists_numeric_outputs(tmp_path):
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
graph = _dxf_recipe()
ok, errors, outputs = runner.validate_graph(graph, tmp_path)
assert ok, errors
keys = {o["key"] for o in outputs}
assert {"t-dxf.points_in_pct", "t-dxf.n_points"} <= keys
assert all(set(o) == {"key", "quantity", "unit"} for o in outputs)
def test_validate_reports_a_missing_file(tmp_path):
ok, errors, outputs = runner.validate_graph(_dxf_recipe(), tmp_path)
assert not ok and errors and outputs == []
+109 -17
View File
@@ -10,12 +10,20 @@ import asyncio
import io
import json
import os
import re
from pathlib import Path
import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError
from src.vision.runner import GraphBuildError, RunOutcome, engine_version, run_graph
from src.vision.runner import (
GraphBuildError,
RunOutcome,
engine_version,
run_graph,
validate_graph,
)
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
@@ -31,6 +39,69 @@ _MAX_CONCURRENCY = int(os.environ.get("VISION_MAX_CONCURRENCY", "2"))
_run_slots = asyncio.Semaphore(_MAX_CONCURRENCY)
# #46: radice sotto cui vivono le cartelle delle ricette (volume uploads,
# montato in sola lettura nel worker). `recipe_dir` e' relativo a questa radice.
_RECIPES_ROOT_DEFAULT = "/app/uploads"
def _unprocessable(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail
)
def _resolve_recipe_dir(recipe_dir: str | None) -> Path | None:
"""Percorso RELATIVO sotto VISION_RECIPES_ROOT -> cartella assoluta.
Assoluto, `..` o fuori radice (anche via symlink) -> 422. Vuoto -> None.
"""
if recipe_dir is None or not recipe_dir.strip():
return None
rel = recipe_dir.strip()
# Letto a ogni richiesta (non a import) cosi' i test possono cambiarlo.
root = Path(os.environ.get("VISION_RECIPES_ROOT", _RECIPES_ROOT_DEFAULT)).resolve()
if (
Path(rel).is_absolute()
# #46: lettera di unita (C:/x) rifiutata su ogni OS; UNC coperto da startswith("\\")
or re.match(r"^[A-Za-z]:", rel)
or rel.startswith(("/", "\\"))
or ".." in Path(rel.replace("\\", "/")).parts
):
raise _unprocessable("recipe_dir must be a relative path under the recipes root")
resolved = (root / rel).resolve()
if resolved != root and root not in resolved.parents:
raise _unprocessable("recipe_dir is outside the recipes root")
return resolved
def _parse_graph(graph: str) -> dict:
try:
parsed = json.loads(graph)
except json.JSONDecodeError as exc:
raise _unprocessable(f"graph is not valid JSON: {exc}") from exc
if not isinstance(parsed, dict):
raise _unprocessable(
f"graph must be a JSON object, got {type(parsed).__name__}"
)
return parsed
def _parse_calibration(calibration: str | None) -> dict | None:
if calibration is None or not calibration.strip():
return None
try:
parsed = json.loads(calibration)
except json.JSONDecodeError as exc:
raise _unprocessable(f"calibration is not valid JSON: {exc}") from exc
if not isinstance(parsed, dict) or not (
parsed.get("mm_per_px") or parsed.get("px_per_mm")
):
raise _unprocessable(
"calibration must be {'mm_per_px': float} or {'px_per_mm': float}"
)
return parsed
class _ImageDecodeError(Exception):
"""The upload is not a decodable image - raised inside the worker thread
so it survives the trip through `asyncio.to_thread`."""
@@ -44,7 +115,12 @@ async def health() -> dict:
return {"status": "ok", "engine_version": engine_version()}
def _decode_and_run(content: bytes, graph: dict) -> RunOutcome:
def _decode_and_run(
content: bytes,
graph: dict,
calibration: dict | None = None,
recipe_dir: Path | None = None,
) -> RunOutcome:
"""The synchronous, CPU/IO-bound half of a request: PIL decode plus
`run_graph`. Runs in a worker thread (see `run` below) so the event loop -
and therefore `/health` - stays responsive while it executes. Once this
@@ -56,27 +132,19 @@ def _decode_and_run(content: bytes, graph: dict) -> RunOutcome:
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
except (UnidentifiedImageError, OSError) as exc:
raise _ImageDecodeError(str(exc)) from exc
return run_graph(frame, graph)
return run_graph(frame, graph, calibration=calibration, recipe_dir=recipe_dir)
@app.post("/run")
async def run(
image: UploadFile = File(...),
graph: str = Form(...),
recipe_dir: str | None = Form(None),
calibration: str | None = Form(None),
) -> dict:
try:
parsed_graph = json.loads(graph)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"graph is not valid JSON: {exc}",
) from exc
if not isinstance(parsed_graph, dict):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"graph must be a JSON object, got {type(parsed_graph).__name__}",
)
parsed_graph = _parse_graph(graph)
resolved_dir = _resolve_recipe_dir(recipe_dir)
parsed_calibration = _parse_calibration(calibration)
content = await image.read()
@@ -89,7 +157,13 @@ async def run(
async with _run_slots:
try:
outcome = await asyncio.wait_for(
asyncio.to_thread(_decode_and_run, content, parsed_graph),
asyncio.to_thread(
_decode_and_run,
content,
parsed_graph,
parsed_calibration,
resolved_dir,
),
timeout=_RUN_TIMEOUT_S,
)
except asyncio.TimeoutError as exc:
@@ -130,4 +204,22 @@ async def run(
"engine_version": outcome.engine_version,
"duration_ms": outcome.duration_ms,
"invalid_outputs": outcome.invalid_outputs,
# #46: contorni per l'overlay sull'immagine e esito VisionSuite.
"overlay": outcome.overlay,
"outcome": outcome.outcome,
}
@app.post("/validate")
async def validate(
graph: str = Form(...),
recipe_dir: str | None = Form(None),
) -> dict:
"""Validates a v2 recipe (not a draft: a missing meta file is an error)
and lists its numeric outputs. No image, so it is cheap: no run slot."""
parsed_graph = _parse_graph(graph)
resolved_dir = _resolve_recipe_dir(recipe_dir)
ok, errors, outputs = await asyncio.to_thread(
validate_graph, parsed_graph, resolved_dir
)
return {"ok": ok, "errors": errors, "outputs": outputs}
+94 -7
View File
@@ -45,13 +45,15 @@ def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
def a_circle_graph() -> dict:
from visionsuite.task import Task
from visionsuite.task.tools import DetectCircleTool, DiameterTool
from visionsuite.tools.detect import DetectCircle
from visionsuite.tools.dimensions import Diameter
task = Task(name="prova")
found = task.add(DetectCircleTool(name="disco"))
found = task.add(DetectCircle(name="disco"))
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
found.scan_size = 30.0
quota = task.add(DiameterTool(name="diametro"))
quota = task.add(Diameter(name="diametro"))
quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px
quota.link("circle", found, "circle")
return task.to_dict()
@@ -140,7 +142,7 @@ async def test_a_graph_that_is_not_an_object_is_refused():
@pytest.mark.asyncio
async def test_a_tool_without_a_kind_is_refused_not_crashed():
graph = {"schema_version": 1, "name": "p", "tools": [{"id": "a", "name": "x"}]}
graph = {"schema_version": 2, "name": "p", "tools": [{"id": "a", "name": "x"}]}
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.post(
@@ -151,7 +153,7 @@ async def test_a_tool_without_a_kind_is_refused_not_crashed():
assert response.status_code == 422
assert "malformed graph" in response.json()["detail"].lower()
assert "kind" in response.json()["detail"].lower()
assert "tipo sconosciuto" in response.json()["detail"].lower() # #46: messaggio v2
# ---------------------------------------------------------------------------
@@ -167,7 +169,7 @@ async def test_a_run_past_its_budget_answers_504_and_health_still_answers(
):
monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 0.1)
def slow_run_graph(frame, graph):
def slow_run_graph(frame, graph, **kwargs):
time.sleep(0.5) # off the event loop - it runs inside asyncio.to_thread
return runner.RunOutcome(engine_version="a" * 40)
@@ -197,7 +199,7 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch):
monkeypatch.setattr(worker_main, "_run_slots", asyncio.Semaphore(1))
monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 2.0)
def slow_run_graph(frame, graph):
def slow_run_graph(frame, graph, **kwargs):
time.sleep(0.3)
return runner.RunOutcome(engine_version="a" * 40)
@@ -222,3 +224,88 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch):
assert second_response.status_code == 503
assert first_response.status_code == 200
# ---------------------------------------------------------------------------
# #46: recipe_dir, calibration, overlay, /validate.
# ---------------------------------------------------------------------------
async def _post(path: str, **kwargs):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
return await client.post(path, **kwargs)
@pytest.mark.asyncio
async def test_run_returns_overlay_and_outcome():
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(a_circle_graph())},
)
body = response.json()
assert response.status_code == 200
assert body["overlay"] and "tool_id" in body["overlay"][0]
assert body["outcome"] in ("pass", "warning", "fail")
@pytest.mark.asyncio
async def test_run_accepts_a_calibration():
graph = a_circle_graph()
for tool in graph["tools"]:
if tool["kind"] == "Diameter":
tool["values"]["nominal_unit"] = "mm"
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(graph),
"calibration": json.dumps({"px_per_mm": 2.0})},
)
body = response.json()
assert response.status_code == 200, body
assert body["failures"] == []
@pytest.mark.asyncio
@pytest.mark.parametrize("bad", ["../etc", "a/../../b", "/abs/path", "C:/x/y"])
async def test_a_recipe_dir_outside_the_root_is_refused(bad, monkeypatch, tmp_path):
monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path))
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad},
)
assert response.status_code == 422
response = await _post(
"/validate", data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad}
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_validate_ok_and_with_a_missing_file(monkeypatch, tmp_path):
monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path))
response = await _post("/validate", data={"graph": json.dumps(a_circle_graph())})
body = response.json()
assert response.status_code == 200 and body["ok"] is True
assert any(o["key"].endswith(".diameter") for o in body["outputs"])
(tmp_path / "r1").mkdir()
graph = {
"schema_version": 2, "name": "d",
"meta": {"dxf": {"d": {"path": "mancante.dxf"}}},
"tools": [{"id": "t", "name": "c", "kind": "CompareDxf",
"values": {"dxf": "d", "mm_per_px": 0.25,
"band_plus": 8.0, "band_minus": 8.0},
"links": {}}],
}
response = await _post(
"/validate", data={"graph": json.dumps(graph), "recipe_dir": "r1"}
)
body = response.json()
assert response.status_code == 200
assert body["ok"] is False and body["errors"] and body["outputs"] == []
Generated
+142 -22
View File
@@ -626,6 +626,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
]
[[package]]
name = "ezdxf"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fonttools" },
{ name = "numpy" },
{ name = "pyparsing" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/d7/1b7be8db364f1c4838dfc1a40ca96577aba405deabf896a4eb3aaeb15a62/ezdxf-1.4.4.tar.gz", hash = "sha256:da5a5e0e6bdbb6656f9c017b47edc7eafceb419d61a2b5de64ffb344c168e593", size = 1866886, upload-time = "2026-05-14T09:19:19.511Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/d0/bc515b7f5596bc3d55b93fc9f2c2b4926e2587af6bfae0486ef7cc7ddf43/ezdxf-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:39aadab5bc7a05deddbd0db9b984c8a4bd28bd4c1ac2c5ee03f0d1b096272a88", size = 3553590, upload-time = "2026-05-14T09:25:28.042Z" },
{ url = "https://files.pythonhosted.org/packages/a7/73/207132f13a5280d186f2441d9125639fdc7ddeabf56c2d5da4d691e81ce6/ezdxf-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2ed65b191113a82133ba47daa6ebdd540b487b17394b1e327e9511dde87ad924", size = 3008276, upload-time = "2026-05-14T09:25:29.424Z" },
{ url = "https://files.pythonhosted.org/packages/82/73/b01af87269726e3719abc5ff694b843b710ef8fc345d21013e90d70e44d8/ezdxf-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c3c8f2c2b62fe5b6c9b62ab8257b697535d62b0c79acf3a5040bd95edfab451", size = 3001750, upload-time = "2026-05-14T09:25:31.146Z" },
{ url = "https://files.pythonhosted.org/packages/dd/92/284d7ca6a9da107966c7bd9d81c92245985235bc432fe0837982ef80630a/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d68d5ab9e1071319030da3af27590f34c38c8544a5ea638d9a4a800a96abb3c2", size = 5802382, upload-time = "2026-05-14T09:27:28.462Z" },
{ url = "https://files.pythonhosted.org/packages/7d/fe/e4f37730ca5b5bf7de3a968a26126b404f60324d0b8ae9d59d11d6e55740/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f75a4f2924ebdda0f5b2779ff2135ba92de2596c95a8fa9b1d9ebcabea1be41", size = 5794671, upload-time = "2026-05-14T09:27:09.867Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3f/c3e7ab74f58cb69ef985fb71eda859b5c287cc506c65cbffe7e55c7cf2d4/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0eec8a2095b645420a67560e99041c0d25afefd39eab5bff8c736fdb32211a75", size = 5767366, upload-time = "2026-05-14T09:27:30.108Z" },
{ url = "https://files.pythonhosted.org/packages/66/7a/b7070bf05e75b3e709a6fe5ae5bcd3bd899e987ef84ff25cf28327519c03/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d87a85052e92b9c28e54605dbad65f779a3b61e6288e992d6ef00df7bde4bed3", size = 5830221, upload-time = "2026-05-14T09:27:12.074Z" },
{ url = "https://files.pythonhosted.org/packages/78/cf/a990289808c261c52f5b219484d136a8748773c99a6d25c84f55a0353fea/ezdxf-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:473d68c8f2b04ace513efa290a1cd0d3a4a589cdff48124f015f0e51034668b2", size = 2957599, upload-time = "2026-05-14T09:23:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/98648d221ffa047b5ba898c8e152010861dc71a66b531dcf15053075010e/ezdxf-1.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d723d35d08223318c17080a32a05192a1449e544dee867c9d9c6f25998fac303", size = 3563916, upload-time = "2026-05-14T09:25:32.879Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f1/84bbaa571412f840c22deeae185e54d4a67aa52f03e1728fd8425748c548/ezdxf-1.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6b0c73b46085521bdbd4a3e7d88998da7bec5713ba89bbb502fdeffe6b42fc7e", size = 3016857, upload-time = "2026-05-14T09:25:34.607Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ff/f2eb77d22d26aa98abbe0961470871d8b85ef4e73e5527f544d25f958d6e/ezdxf-1.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac3e83a70c1f866dd5114192a6fab6e75cc83b75e907bc08c5d94b98cce14cc0", size = 3003336, upload-time = "2026-05-14T09:25:35.947Z" },
{ url = "https://files.pythonhosted.org/packages/65/7d/2a6ddd6beef5d9a8593078619f3920a0cd76b1db4c6a974ac37045543219/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ebe09717ab6636bf48780eabdab51e3ab2135f7963bb52452444b6d95e03d12", size = 5788485, upload-time = "2026-05-14T09:27:32.176Z" },
{ url = "https://files.pythonhosted.org/packages/e7/29/2094fd7719146ca7bb236be9751b5929d431f643cbab669aed89cf121883/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44af248af0860fe8b1a39ca40e1cd2fa8ac37b22142b14b20c6a0caf1b50f932", size = 5816781, upload-time = "2026-05-14T09:27:13.96Z" },
{ url = "https://files.pythonhosted.org/packages/19/8d/669b8047cca162204c53b874183d73b5f6c4b1d5d48fa1e515a32d56f5ac/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02a097794625e9fc948e637dfe07057e98167b4fe215357a59645759fef6db3f", size = 5754006, upload-time = "2026-05-14T09:27:33.858Z" },
{ url = "https://files.pythonhosted.org/packages/5b/44/b753997173327250e43ae50f11f0c69016b1e78482e0bd3857c3495236e7/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f093bd2228f5782d483aed3bd89cdb74b672b5f90c677c4125cd236ee5649151", size = 5861860, upload-time = "2026-05-14T09:27:15.758Z" },
{ url = "https://files.pythonhosted.org/packages/0b/99/2e81cb168e0f09adfb05fb40e5f890ff87ac2c00e98079cf09837dd76330/ezdxf-1.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:3e518d9ed4dcbe525d57f14c25c5e592f82660a690fdec39b03b092e6cdc4771", size = 2963238, upload-time = "2026-05-14T09:23:47.924Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/97aa3ba1ba923e4098169de5bca380ee1430e2b34d0dd85b73007e34df16/ezdxf-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:278a09a845d67a4f893aebe9ef8dcd8894ece9f255d7d8bb21719ffd902109b8", size = 3555283, upload-time = "2026-05-14T09:25:37.346Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b1/8ca9408a2b382a806837e72d35f10931fdd9f53fe66cf208a6340358fbc5/ezdxf-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0a787f8ebcbdcb9798c6fcfe90c23afd1a0993c8d4302b6cce5d8f19ae052ae", size = 3012646, upload-time = "2026-05-14T09:25:39.01Z" },
{ url = "https://files.pythonhosted.org/packages/8b/aa/1e8d3130eaabb780c5e8adfdc1ead023bb04468779b18169d60f7ce5b291/ezdxf-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c451d1c20f08b30c735ca4d650fee5b44147402f4b5662f8550053ed8a3009c2", size = 2999048, upload-time = "2026-05-14T09:25:40.348Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ca/1dd390b79df9c104c2d4b5964e80db25362edf704ac742c9db9b279658a5/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d3d9a6663993fc644751647fd7843057b51742ffac27c48ce28ea9f81239613", size = 5747890, upload-time = "2026-05-14T09:27:36.028Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1e/226a6e636ae533e0bc0afe4435d1058fac173564e4af4102862f055a46d9/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c022cf094d21ad3db68557aa83628e4152355e8a8bef0fffd4f71a9ea325df", size = 5774707, upload-time = "2026-05-14T09:27:17.625Z" },
{ url = "https://files.pythonhosted.org/packages/24/a3/7e33c9036de944b5446982bb629d698356e48b00c4d173768f52bdeeff4b/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3be59c2a3a93585412a2a37af29b3ad7574fa3e429e624924c3285d0573388b3", size = 5713753, upload-time = "2026-05-14T09:27:37.55Z" },
{ url = "https://files.pythonhosted.org/packages/36/9e/ac0cdc3a8623fc38aa16670f6537aa810bc1381217a51ac3481299bbc986/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:44a9716860b59dddd49a3708c04ff4580ad1e7de92ac93bb3213d85fba5ac93f", size = 5813895, upload-time = "2026-05-14T09:27:19.514Z" },
{ url = "https://files.pythonhosted.org/packages/dd/22/b511a8d9ea8f23447a67fe8977213fac5e4e69df30130a3ef31043613eaf/ezdxf-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:207eed544417464ffaf2570880d58a792ecd2534c9c2dede45e895f3200b77e6", size = 2960937, upload-time = "2026-05-14T09:23:49.361Z" },
{ url = "https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl", hash = "sha256:666edda631ba717270293b734f5d58dd97a1d1aba4787187f09d0cc584645865", size = 1331217, upload-time = "2026-05-14T09:19:26.601Z" },
]
[[package]]
name = "fastapi"
version = "0.136.1"
@@ -1079,8 +1118,8 @@ name = "numba"
version = "0.67.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "llvmlite", marker = "python_full_version >= '3.13'" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "llvmlite" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/90/2544f4e3a61e501d6c9a5418fd4b905323222693d54a02cab0106a0af865/numba-0.67.0.tar.gz", hash = "sha256:cd75aa535b33fa05d9d930b1ae8af9f97a2881e96d72dfb38ec9b78284d9f851", size = 2836515, upload-time = "2026-08-11T23:04:00.174Z" }
wheels = [
@@ -1185,7 +1224,7 @@ name = "opencv-python"
version = "5.0.0.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" }
wheels = [
@@ -1570,6 +1609,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" },
]
[[package]]
name = "pypdfium2"
version = "5.8.0"
@@ -1751,7 +1799,7 @@ name = "scipy"
version = "1.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
wheels = [
@@ -1980,6 +2028,7 @@ vision = [
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
]
vision-worker = [
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
@@ -1991,6 +2040,7 @@ vision-worker = [
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
]
[package.metadata]
@@ -2032,6 +2082,7 @@ requires-dist = [
{ name = "vs-measure", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-measure" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-pm2d" },
{ name = "vs-task", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-task" },
{ name = "vs-tools", extras = ["locate", "dxf"], marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-tools" },
{ name = "weasyprint", marker = "extra == 'server'", specifier = ">=62.0" },
]
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
@@ -2157,8 +2208,14 @@ name = "visionsuite"
version = "0.0.1"
source = { editable = "vendor/visionsuite/packages/vs-core" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
]
[package.optional-dependencies]
dxf = [
{ name = "ezdxf" },
]
[package.metadata]
@@ -2168,42 +2225,70 @@ requires-dist = [
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "opencv-python", specifier = ">=4.8" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
]
provides-extras = ["dev", "fast", "dxf"]
[[package]]
name = "vs-dxf"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-dxf" }
dependencies = [
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pydantic" },
{ name = "scipy" },
{ name = "visionsuite", extra = ["dxf"] },
{ name = "vs-measure" },
]
[package.metadata]
requires-dist = [
{ name = "numpy", specifier = ">=1.26" },
{ name = "opencv-python", specifier = ">=4.9" },
{ name = "pydantic", specifier = ">=2.6" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
{ name = "scipy", specifier = ">=1.12" },
{ name = "visionsuite", extras = ["dxf"], editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" },
]
provides-extras = ["dev"]
[[package]]
name = "vs-measure"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-measure" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "scipy", marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
{ name = "scipy" },
{ name = "visionsuite" },
]
[package.metadata]
requires-dist = [
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "scipy", specifier = ">=1.11" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
]
provides-extras = ["dev"]
provides-extras = ["dev", "fast"]
[[package]]
name = "vs-pm2d"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-pm2d" }
dependencies = [
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
{ name = "numba", marker = "python_full_version >= '3.13'" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
{ name = "pillow", marker = "python_full_version >= '3.13'" },
{ name = "python-multipart", marker = "python_full_version >= '3.13'" },
{ name = "uvicorn", extra = ["standard"], marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
{ name = "fastapi" },
{ name = "numba" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "visionsuite" },
]
[package.metadata]
@@ -2226,19 +2311,54 @@ name = "vs-task"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-task" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
{ name = "visionsuite" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.132" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-stat", marker = "extra == 'stat'", editable = "vendor/visionsuite/packages/vs-stat" },
]
provides-extras = ["dev", "stat", "server"]
[[package]]
name = "vs-tools"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-tools" }
dependencies = [
{ name = "numpy" },
{ name = "visionsuite" },
{ name = "vs-measure" },
{ name = "vs-task" },
]
[package.optional-dependencies]
dxf = [
{ name = "vs-dxf" },
]
locate = [
{ name = "vs-pm2d" },
]
[package.metadata]
requires-dist = [
{ name = "ezdxf", marker = "extra == 'dev'", specifier = ">=1.0" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-dxf", marker = "extra == 'dxf'", editable = "vendor/visionsuite/packages/vs-dxf" },
{ name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" },
{ name = "vs-pm2d", marker = "extra == 'locate'", editable = "vendor/visionsuite/packages/vs-pm2d" },
{ name = "vs-task", editable = "vendor/visionsuite/packages/vs-task" },
{ name = "vs-tools", extras = ["locate", "dxf"], marker = "extra == 'dev'", editable = "vendor/visionsuite/packages/vs-tools" },
]
provides-extras = ["dev"]
provides-extras = ["locate", "dxf", "dev"]
[[package]]
name = "watchfiles"