Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74fe5665b5 | |||
| be904bac07 | |||
| 011c08c9d8 | |||
| 82ad88538d | |||
| bafc776ef7 | |||
| 8fcb4796d5 | |||
| 8d7fa9a12a | |||
| c911449b7c | |||
| ac8604eff5 | |||
| 631ba4ee05 | |||
| 40eed5c2af | |||
| 275bbd1a5f | |||
| 95d643d6d8 | |||
| 9306fbf653 | |||
| 1a5e98f072 | |||
| fdfcf05951 | |||
| 26884ebf4f | |||
| 8b3d3a8424 | |||
| 153fafd1b4 | |||
| cf8fab4c08 | |||
| b7cb061fee | |||
| 6a8ad4dae4 | |||
| d750cf9e0e | |||
| 2564498915 | |||
| 21f8bae5ac | |||
| c1f8a5e2ff | |||
| dee27ac8e1 | |||
| 736b853e22 | |||
| 627ae15591 | |||
| c6d4260ede | |||
| df1727be73 | |||
| 459d1c71ea | |||
| 05e3faa9d4 | |||
| dc94b2e778 | |||
| 3c55550d93 | |||
| e07c4d4eee | |||
| 0b45f1c35a | |||
| 743f246cbf | |||
| f541f4a810 | |||
| dffb11f9f2 | |||
| c8dd5eecfd | |||
| f7d6d008c7 | |||
| 837f3d1069 | |||
| f5a2affa0f | |||
| 7ef28b7a9f | |||
| 06b87a15b3 | |||
| 3b3ecceb2a | |||
| 3899d5ecd9 | |||
| f986c172c2 | |||
| e44ac3129f | |||
| 4e52095423 | |||
| 23bfc93f1c | |||
| 07e23b957d | |||
| c1c7ae8d0e | |||
| cf49695140 | |||
| c9e01a223b | |||
| 3690c35c47 | |||
| fc1902a1bb | |||
| 6648882ec1 | |||
| b5aa19170e | |||
| 2d03577165 | |||
| 27dc5e77af | |||
| 8e3316957c | |||
| 3c1d5f1323 | |||
| db5d686938 | |||
| c764cf73c6 | |||
| 86290274e5 | |||
| ee0f65ed39 | |||
| 18d0db8a0c | |||
| e640c4a36e | |||
| accf416cc9 | |||
| 84a4b5f34d | |||
| 9238f8926b |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,13 @@ WORKDIR /app/flask_app
|
||||
RUN uv run --project /app pybabel compile -d translations && \
|
||||
chown -R app:app /app /tmp/uv-cache
|
||||
|
||||
# Commit mostrato nel piede di pagina (#70). .git e' escluso dal contesto di
|
||||
# build (.dockerignore), quindi lo passa chi costruisce; facoltativo, senza il
|
||||
# piede mostra solo la versione. Uso:
|
||||
# APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client
|
||||
ARG APP_COMMIT=""
|
||||
ENV APP_COMMIT=${APP_COMMIT}
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
@@ -333,6 +333,7 @@ TieMeasureFlow/
|
||||
|---|---|
|
||||
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
|
||||
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
|
||||
| `APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client` | Ricostruisci il client mostrando nel piè di pagina il commit (oltre alla versione di `pyproject.toml`); senza `APP_COMMIT` compare solo la versione |
|
||||
| `docker compose logs -f server` | Segui log server in tempo reale |
|
||||
| `docker compose logs -f client` | Segui log client in tempo reale |
|
||||
| `docker compose ps` | Stato di tutti i servizi |
|
||||
|
||||
@@ -75,6 +75,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||
APP_COMMIT: ${APP_COMMIT:-}
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_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:
|
||||
@@ -86,6 +89,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
|
||||
APP_COMMIT: ${APP_COMMIT:-}
|
||||
container_name: tmflow-client
|
||||
restart: unless-stopped
|
||||
env_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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,14 +3,16 @@ from datetime import datetime
|
||||
from src.backend.models.api.tipi import UtcNaiveInput
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
from src.backend.api.middleware.api_key import require_metrologist
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.models.api.statistics import (
|
||||
CapabilityData,
|
||||
@@ -38,6 +40,7 @@ async def _query_measurements(
|
||||
operator_id: int | None = None,
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None, # #58: solo le misure di una sessione
|
||||
) -> list[Measurement]:
|
||||
"""Query measurements with common filters.
|
||||
|
||||
@@ -67,6 +70,9 @@ async def _query_measurements(
|
||||
if serial_number is not None:
|
||||
filters.append(Measurement.serial_number == serial_number)
|
||||
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
|
||||
query = (
|
||||
select(Measurement)
|
||||
.where(and_(*filters) if filters else True)
|
||||
@@ -106,6 +112,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 +120,7 @@ async def get_summary(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
pass_fail_values = [m.pass_fail for m in measurements]
|
||||
return compute_summary(pass_fail_values)
|
||||
@@ -128,6 +136,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 +144,7 @@ async def get_capability(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||
@@ -151,6 +161,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 +169,7 @@ async def get_control_chart(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
timestamps = [m.measured_at for m in measurements]
|
||||
@@ -178,6 +190,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 +199,7 @@ async def get_histogram(
|
||||
measurements = await _query_measurements(
|
||||
db, recipe_id, version_id, subtask_id,
|
||||
date_from, date_to, operator_id, lot_number, serial_number,
|
||||
production_run_id=production_run_id, # #58
|
||||
)
|
||||
values = [float(m.value) for m in measurements]
|
||||
return compute_histogram(values, n_bins)
|
||||
@@ -235,3 +249,122 @@ async def get_recipe_subtasks(
|
||||
})
|
||||
|
||||
return subtasks_list
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
async def get_recipe_runs(
|
||||
recipe_id: int = Query(...),
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""#58: production runs of a recipe (for the "Sessione" filter), newest first."""
|
||||
runs = (await db.execute(
|
||||
select(ProductionRun)
|
||||
.where(ProductionRun.recipe_id == recipe_id)
|
||||
.order_by(ProductionRun.started_at.desc(), ProductionRun.id.desc())
|
||||
)).scalars().all()
|
||||
# #58: conta solo le misure dei run della ricetta, non tutta la tabella.
|
||||
run_ids = [r.id for r in runs]
|
||||
counts = {}
|
||||
if run_ids:
|
||||
counts = dict((await db.execute(
|
||||
select(Measurement.production_run_id, func.count(Measurement.id))
|
||||
.where(Measurement.production_run_id.in_(run_ids))
|
||||
.group_by(Measurement.production_run_id)
|
||||
)).all())
|
||||
operator_ids = {r.operator_id for r in runs}
|
||||
names = {}
|
||||
if operator_ids:
|
||||
names = dict((await db.execute(
|
||||
select(User.id, User.display_name).where(User.id.in_(operator_ids))
|
||||
)).all())
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"station_id": r.station_id,
|
||||
"station_name": r.station.name if r.station else None,
|
||||
"operator_id": r.operator_id,
|
||||
"operator_name": names.get(r.operator_id),
|
||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
||||
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
||||
"status": r.status,
|
||||
"lot_number": r.lot_number,
|
||||
"serial_number": r.serial_number,
|
||||
"cycle_count": r.cycle_count,
|
||||
"measurement_count": counts.get(r.id, 0),
|
||||
}
|
||||
for r in runs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}/task-results")
|
||||
async def get_run_task_results(
|
||||
run_id: int,
|
||||
user: User = Depends(require_metrologist),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
|
||||
|
||||
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
|
||||
il ruolo MeasurementTec e non si puo' riusare qui.
|
||||
"""
|
||||
run = (await db.execute(
|
||||
select(ProductionRun.id).where(ProductionRun.id == run_id)
|
||||
)).scalar_one_or_none()
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Sessione non trovata")
|
||||
|
||||
# #58: una query per i risultati della run, una per i task, una per gli utenti.
|
||||
results = (await db.execute(
|
||||
select(TaskResult)
|
||||
.where(TaskResult.production_run_id == run_id)
|
||||
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
|
||||
)).scalars().all()
|
||||
|
||||
task_ids = {r.task_id for r in results}
|
||||
tasks = {}
|
||||
if task_ids:
|
||||
tasks = {t.id: t for t in (await db.execute(
|
||||
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
|
||||
)).scalars().all()}
|
||||
user_ids = {r.user_id for r in results}
|
||||
names = {}
|
||||
if user_ids:
|
||||
names = dict((await db.execute(
|
||||
select(User.id, User.display_name).where(User.id.in_(user_ids))
|
||||
)).all())
|
||||
|
||||
counts = {"ok": 0, "nok": 0, "done": 0}
|
||||
out = []
|
||||
for r in results:
|
||||
counts[r.outcome] = counts.get(r.outcome, 0) + 1
|
||||
task = tasks.get(r.task_id)
|
||||
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
|
||||
labels = {
|
||||
f.get("key"): f.get("label") or f.get("key")
|
||||
for f in (task.fields or []) if isinstance(f, dict)
|
||||
} if task else {}
|
||||
out.append({
|
||||
"id": r.id,
|
||||
"task_id": r.task_id,
|
||||
"task_title": task.title if task else None,
|
||||
"task_type": task.task_type if task else None,
|
||||
"outcome": r.outcome,
|
||||
"note": r.note,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"user_name": names.get(r.user_id),
|
||||
"values": [
|
||||
{"key": k, "label": labels.get(k, k), "value": v}
|
||||
for k, v in (r.values or {}).items()
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"key": a.get("key"),
|
||||
"label": labels.get(a.get("key"), a.get("key")),
|
||||
"name": a.get("name"),
|
||||
"path": a.get("path"),
|
||||
}
|
||||
for a in (r.attachments or []) if isinstance(a, dict)
|
||||
],
|
||||
})
|
||||
return {"run_id": run_id, "counts": counts, "results": out}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Task results router: l'esito di un task 'check' (OK/NOK) o 'note' (#54 #55).
|
||||
|
||||
Un 'check' registra ok o nok (nok vuole la nota); un 'note' registra 'done' con i
|
||||
valori dei campi extra e gli allegati. I task di misura non passano di qui: le
|
||||
loro letture sono Measurement.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.api.middleware.api_key import require_measurement_tec
|
||||
from src.backend.api.routers.files import (
|
||||
_UPLOAD_CHUNK_SIZE, detect_file_type, sanitize_filename,
|
||||
)
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.models.api.task_result import TaskResultCreate, TaskResultResponse
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
from src.backend.models.orm.task_result import TaskResult
|
||||
from src.backend.models.orm.user import User
|
||||
|
||||
router = APIRouter(prefix="/api/task-results", tags=["task-results"])
|
||||
|
||||
|
||||
def _unprocessable(detail: str) -> HTTPException:
|
||||
# 422 con detail testuale: l'api_client del frontend legge solo `detail`
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=detail)
|
||||
|
||||
|
||||
# Radice degli allegati degli esiti: sotto upload_path, mai cancellabile (audit).
|
||||
ATTACHMENT_DIR = "task_results"
|
||||
|
||||
|
||||
def _safe_relative_path(path: str) -> bool:
|
||||
"""Allegato: relativo, dentro task_results/, senza '..', unita' o backslash."""
|
||||
if path.startswith("/") or "\\" in path or ":" in path:
|
||||
return False
|
||||
parts = path.split("/")
|
||||
return ".." not in parts and parts[0] == ATTACHMENT_DIR and len(parts) > 1
|
||||
|
||||
|
||||
@router.post("/attachments", status_code=status.HTTP_201_CREATED)
|
||||
async def upload_attachment(
|
||||
file: UploadFile = File(...),
|
||||
_user: User = Depends(require_measurement_tec),
|
||||
):
|
||||
"""Carica l'allegato di un task 'note' (l'operatore non ha accesso a /api/files/upload).
|
||||
|
||||
Stessi controlli di upload_file: tipo dai magic bytes, limite di dimensione
|
||||
con lettura a blocchi, nome ripulito. Salva sotto task_results/<AAAA>/<MM>/.
|
||||
"""
|
||||
max_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
buffer = bytearray()
|
||||
while True:
|
||||
chunk = await file.read(_UPLOAD_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size exceeds maximum {settings.max_upload_size_mb}MB",
|
||||
)
|
||||
content = bytes(buffer)
|
||||
|
||||
detected = detect_file_type(content)
|
||||
if detected is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File type not allowed. Must be a recognised image or PDF.",
|
||||
)
|
||||
_mime, ext = detected
|
||||
|
||||
# nome originale ripulito (per mostrarlo); sul disco solo uuid + estensione rilevata
|
||||
name = sanitize_filename(file.filename or "")
|
||||
now = datetime.now(timezone.utc)
|
||||
storage_dir = settings.upload_path / ATTACHMENT_DIR / f"{now:%Y}" / f"{now:%m}"
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = storage_dir / f"{uuid.uuid4().hex}{ext}"
|
||||
target.write_bytes(content)
|
||||
|
||||
return {
|
||||
"file_path": str(target.relative_to(settings.upload_path)).replace("\\", "/"),
|
||||
"name": name,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=TaskResultResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task_result(
|
||||
data: TaskResultCreate,
|
||||
user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Registra l'esito di un task 'check' o 'note'."""
|
||||
task = (await db.execute(
|
||||
select(RecipeTask).where(RecipeTask.id == data.task_id)
|
||||
)).scalar_one_or_none()
|
||||
if task is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||
|
||||
note = data.note.strip() if data.note else None
|
||||
note = note or None
|
||||
values: Optional[dict] = None
|
||||
attachments: Optional[list] = None
|
||||
|
||||
if task.task_type == "check":
|
||||
if data.outcome not in ("ok", "nok"):
|
||||
raise _unprocessable("Un controllo si registra come ok o nok")
|
||||
if data.outcome == "nok" and not note:
|
||||
raise _unprocessable("Per un NOK serve la nota")
|
||||
elif task.task_type == "note":
|
||||
if data.outcome != "done":
|
||||
raise _unprocessable("Una nota si registra come done")
|
||||
fields = {f["key"]: f for f in (task.fields or [])}
|
||||
values = {}
|
||||
for key, value in (data.values or {}).items():
|
||||
if key not in fields:
|
||||
raise _unprocessable(f"Campo '{key}' non previsto dal task")
|
||||
if fields[key]["type"] == "number" and value.strip() != "":
|
||||
try:
|
||||
float(value.replace(",", "."))
|
||||
except ValueError:
|
||||
raise _unprocessable(f"Il campo '{fields[key]['label']}' deve essere un numero")
|
||||
values[key] = value
|
||||
attachments = []
|
||||
for att in data.attachments or []:
|
||||
if not _safe_relative_path(att.path):
|
||||
raise _unprocessable(
|
||||
f"Percorso allegato non valido (deve stare in {ATTACHMENT_DIR}/): {att.path}"
|
||||
)
|
||||
if att.key is not None and att.key not in fields:
|
||||
raise _unprocessable(f"Campo allegato '{att.key}' non previsto dal task")
|
||||
attachments.append(att.model_dump())
|
||||
values = values or None
|
||||
attachments = attachments or None
|
||||
else:
|
||||
raise _unprocessable(
|
||||
f"I task di tipo '{task.task_type}' non registrano un esito"
|
||||
)
|
||||
|
||||
if data.production_run_id is not None:
|
||||
run = (await db.execute(
|
||||
select(ProductionRun).where(ProductionRun.id == data.production_run_id)
|
||||
)).scalar_one_or_none()
|
||||
if run is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Production run not found"
|
||||
)
|
||||
if run.status == "closed":
|
||||
raise _unprocessable("La produzione e' gia' chiusa")
|
||||
if run.version_id != task.version_id:
|
||||
raise _unprocessable("Il task non appartiene alla versione di questa produzione")
|
||||
|
||||
result = TaskResult(
|
||||
task_id=task.id,
|
||||
version_id=task.version_id, # presa dal task, non dal client
|
||||
production_run_id=data.production_run_id,
|
||||
user_id=user.id,
|
||||
outcome=data.outcome,
|
||||
note=note,
|
||||
values=values,
|
||||
attachments=attachments,
|
||||
)
|
||||
db.add(result)
|
||||
await db.flush()
|
||||
await db.refresh(result) # created_at e' un server_default
|
||||
return TaskResultResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskResultResponse])
|
||||
async def list_task_results(
|
||||
production_run_id: Optional[int] = Query(None, gt=0),
|
||||
task_id: Optional[int] = Query(None, gt=0),
|
||||
_user: User = Depends(require_measurement_tec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Esiti registrati, filtrabili per produzione e/o task; il piu' recente prima."""
|
||||
stmt = select(TaskResult)
|
||||
if production_run_id is not None:
|
||||
stmt = stmt.where(TaskResult.production_run_id == production_run_id)
|
||||
if task_id is not None:
|
||||
stmt = stmt.where(TaskResult.task_id == task_id)
|
||||
rows = (await db.execute(
|
||||
stmt.order_by(TaskResult.created_at.desc(), TaskResult.id.desc())
|
||||
)).scalars().all()
|
||||
return [TaskResultResponse.model_validate(r) for r in rows]
|
||||
@@ -17,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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,19 +5,25 @@ the numbers: that is what lets a station's configuration change without touching
|
||||
the frontend or the recipes.
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
|
||||
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, validate_file_size
|
||||
from src.backend.api.routers.files import (
|
||||
ALLOWED_IMAGE_TYPES, resolve_upload_path, validate_file_size,
|
||||
)
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import get_db
|
||||
from src.backend.models.api.vision import (
|
||||
VisionExecuteResponse, VisionMeasurementResult, VisionReferenceImageResponse,
|
||||
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
|
||||
VisionRecipeUploadResponse, VisionReferenceImageResponse,
|
||||
)
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import vision_service
|
||||
@@ -126,7 +132,7 @@ async def execute(
|
||||
) -> VisionExecuteResponse:
|
||||
task = await _task_or_404(db, task_id)
|
||||
|
||||
result, saved = await vision_service.execute_task(
|
||||
result, saved, report = await vision_service.execute_task(
|
||||
db,
|
||||
task,
|
||||
await _read_image(image),
|
||||
@@ -135,6 +141,7 @@ async def execute(
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
production_run_id=production_run_id,
|
||||
image_ext=_IMAGE_EXTENSIONS.get(image.content_type, "png"),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -147,6 +154,111 @@ async def execute(
|
||||
)
|
||||
for m in saved
|
||||
],
|
||||
outcome=report.get("outcome"),
|
||||
overlay=report.get("overlay") or [],
|
||||
image_url=f"/api/vision/results/{result.id}/image",
|
||||
failures=report.get("failures") or [],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/results/{result_id}/image")
|
||||
async def get_result_image(
|
||||
result_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_measurement_tec),
|
||||
) -> FileResponse:
|
||||
"""The image a vision run measured, as saved by `execute` (#46)."""
|
||||
result = (await db.execute(
|
||||
select(VisionResult).where(VisionResult.id == result_id)
|
||||
)).scalar_one_or_none()
|
||||
if result is None or not result.image_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Result image not found",
|
||||
)
|
||||
return FileResponse(resolve_upload_path(result.image_path))
|
||||
|
||||
|
||||
def _422(errors: list[str]) -> HTTPException:
|
||||
# detail e' una lista di stringhe: il frontend legge i 422 solo da `detail`.
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=errors,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
||||
async def upload_recipe(
|
||||
task_id: int,
|
||||
recipe: UploadFile = File(...),
|
||||
files: list[UploadFile] = File(default=[]),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> VisionRecipeUploadResponse:
|
||||
"""Load a VisionSuite v2 recipe plus its meta files onto a task (#46)."""
|
||||
task = await _task_or_404(db, task_id)
|
||||
if task.task_type not in vision_service.VISION_TASK_TYPES:
|
||||
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
|
||||
|
||||
raw = await recipe.read()
|
||||
if not validate_file_size(len(raw)):
|
||||
raise _422([f"recipe exceeds {settings.max_upload_size_mb}MB"])
|
||||
try:
|
||||
graph = json.loads(raw)
|
||||
except ValueError:
|
||||
raise _422(["the recipe is not valid JSON"])
|
||||
if not isinstance(graph, dict):
|
||||
raise _422(["the recipe must be a JSON object"])
|
||||
|
||||
uploads: list[tuple[str, bytes]] = []
|
||||
for f in files:
|
||||
content = await f.read()
|
||||
if not validate_file_size(len(content)):
|
||||
raise _422([f"file {f.filename!r} exceeds {settings.max_upload_size_mb}MB"])
|
||||
uploads.append((f.filename or "", content))
|
||||
placed, errors = vision_service.match_recipe_files(graph, uploads)
|
||||
if errors:
|
||||
raise _422(errors)
|
||||
|
||||
# The folder is content-addressed and immutable; writing it before the
|
||||
# validation is harmless (an orphan folder is never referenced) and the
|
||||
# worker needs it on disk to validate meta files.
|
||||
try:
|
||||
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
|
||||
except ValueError as exc:
|
||||
raise _422([str(exc)])
|
||||
check = await vision_service.validate_recipe(graph, recipe_dir)
|
||||
if not check["ok"]:
|
||||
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
|
||||
|
||||
task.vision_json = graph
|
||||
task.vision_recipe_dir = recipe_dir
|
||||
await db.commit()
|
||||
return VisionRecipeUploadResponse(
|
||||
task_id=task.id,
|
||||
recipe_dir=recipe_dir,
|
||||
files=vision_service.list_recipe_files(recipe_dir),
|
||||
outputs=check["outputs"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/recipe", response_model=VisionRecipeResponse)
|
||||
async def get_recipe(
|
||||
task_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> VisionRecipeResponse:
|
||||
task = await _task_or_404(db, task_id)
|
||||
outputs: list[dict] = []
|
||||
if task.vision_json:
|
||||
check = await vision_service.validate_recipe(
|
||||
task.vision_json, task.vision_recipe_dir,
|
||||
)
|
||||
outputs = check.get("outputs", [])
|
||||
return VisionRecipeResponse(
|
||||
task_id=task.id,
|
||||
recipe_dir=task.vision_recipe_dir,
|
||||
recipe=task.vision_json,
|
||||
files=vision_service.list_recipe_files(task.vision_recipe_dir),
|
||||
outputs=outputs,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -22,24 +22,44 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||
),
|
||||
)
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
# Idempotent on purpose (#71): a database born from create_all has no
|
||||
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
|
||||
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
|
||||
# created and `alembic_version` still at 014. Every step checks first.
|
||||
insp = sa.inspect(op.get_bind())
|
||||
|
||||
if 'api_keys' not in insp.get_table_names():
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||
),
|
||||
)
|
||||
existing_idx: set[str] = set()
|
||||
else:
|
||||
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
|
||||
|
||||
if 'ix_api_keys_key_hash' not in existing_idx:
|
||||
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||
if 'ix_api_keys_user_id' not in existing_idx:
|
||||
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||
|
||||
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
|
||||
return # column already gone: nothing else to do
|
||||
|
||||
user_idx = {i['name'] for i in insp.get_indexes('users')}
|
||||
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.drop_index('ix_users_api_key')
|
||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||
if 'ix_users_api_key' in user_idx:
|
||||
batch.drop_index('ix_users_api_key')
|
||||
if 'uq_users_api_key' in user_uq:
|
||||
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||
batch.drop_column('api_key')
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
|
||||
|
||||
Percorso relativo sotto upload_path della cartella immutabile che contiene
|
||||
recipe.json e i file di meta (DXF, modelli .npz, ...).
|
||||
|
||||
Revision ID: 016_vision_recipe_dir
|
||||
Revises: 015_api_key_hash
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '016_vision_recipe_dir'
|
||||
down_revision: Union[str, None] = '015_api_key_hash'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.drop_column('vision_recipe_dir')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
|
||||
|
||||
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
|
||||
|
||||
Revision ID: 017_user_pin
|
||||
Revises: 016_vision_recipe_dir
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '017_user_pin'
|
||||
down_revision: Union[str, None] = '016_vision_recipe_dir'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
|
||||
batch.add_column(sa.Column(
|
||||
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
|
||||
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users') as batch:
|
||||
batch.drop_column('pin_locked_until')
|
||||
batch.drop_column('pin_failed_count')
|
||||
batch.drop_column('pin_hash')
|
||||
@@ -0,0 +1,108 @@
|
||||
"""task: in_loop / at_close / fields, tipo 'check', tabella task_results (#53 #54 #55)
|
||||
|
||||
in_loop: il task fa parte del ciclo di produzione (prima: implicito dal tipo).
|
||||
at_close: il task 'note' si compila alla fine produzione.
|
||||
fields: campi extra del task 'note' (JSON).
|
||||
task_results: esito registrato da un task 'check' (OK/NOK) o 'note' (done).
|
||||
|
||||
Revision ID: 018_task_loop_results
|
||||
Revises: 017_user_pin
|
||||
Create Date: 2026-10-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '018_task_loop_results'
|
||||
down_revision: Union[str, None] = '017_user_pin'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
OLD_TYPES = ('note', 'measure', 'drawing', 'dxf_compare', 'camera_measure')
|
||||
NEW_TYPES = OLD_TYPES + ('check',)
|
||||
# stessi tipi di MEASURING_TASK_TYPES: fino a oggi erano l'unico ciclo possibile
|
||||
LOOP_TYPES = ('measure', 'camera_measure', 'dxf_compare')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.add_column(sa.Column(
|
||||
'in_loop', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch.add_column(sa.Column(
|
||||
'at_close', sa.Boolean(), nullable=False, server_default=sa.false()))
|
||||
batch.add_column(sa.Column('fields', sa.JSON(), nullable=True))
|
||||
# enum allargato come in 011: 'check' = Controllo OK/NOK
|
||||
batch.alter_column(
|
||||
'task_type',
|
||||
existing_type=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||
type_=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||
existing_nullable=False,
|
||||
existing_server_default='note',
|
||||
)
|
||||
|
||||
# i task di misura erano gia' nel ciclo: lo restano
|
||||
recipe_tasks = sa.table(
|
||||
'recipe_tasks', sa.column('task_type', sa.String), sa.column('in_loop', sa.Boolean))
|
||||
op.execute(
|
||||
recipe_tasks.update()
|
||||
.where(recipe_tasks.c.task_type.in_(LOOP_TYPES))
|
||||
.values(in_loop=True)
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'task_results',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column('task_id', sa.Integer(),
|
||||
sa.ForeignKey('recipe_tasks.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('version_id', sa.Integer(),
|
||||
sa.ForeignKey('recipe_versions.id'), nullable=False),
|
||||
sa.Column('production_run_id', sa.Integer(),
|
||||
sa.ForeignKey('production_runs.id', ondelete='SET NULL'), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('outcome', sa.Enum('ok', 'nok', 'done', name='task_outcome_enum'),
|
||||
nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=True),
|
||||
sa.Column('values', sa.JSON(), nullable=True),
|
||||
sa.Column('attachments', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8mb4',
|
||||
)
|
||||
op.create_index('ix_task_results_task_id', 'task_results', ['task_id'])
|
||||
op.create_index('ix_task_results_version_id', 'task_results', ['version_id'])
|
||||
op.create_index(
|
||||
'ix_task_results_production_run_id', 'task_results', ['production_run_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# il tipo 'check' non esiste prima di 018: se un task lo usa, tornare indietro
|
||||
# lo lascerebbe con un valore fuori enum
|
||||
bind = op.get_bind()
|
||||
used = bind.execute(
|
||||
sa.text("SELECT COUNT(*) FROM recipe_tasks WHERE task_type = 'check'")
|
||||
).scalar()
|
||||
if used:
|
||||
raise RuntimeError(
|
||||
f"Downgrade 018 impossibile: {used} task usano il tipo 'check'. "
|
||||
"Cambiare il tipo o eliminare quei task prima di tornare indietro."
|
||||
)
|
||||
|
||||
op.drop_index('ix_task_results_production_run_id', table_name='task_results')
|
||||
op.drop_index('ix_task_results_version_id', table_name='task_results')
|
||||
op.drop_index('ix_task_results_task_id', table_name='task_results')
|
||||
op.drop_table('task_results')
|
||||
|
||||
with op.batch_alter_table('recipe_tasks') as batch:
|
||||
batch.alter_column(
|
||||
'task_type',
|
||||
existing_type=sa.Enum(*NEW_TYPES, name='task_type_enum'),
|
||||
type_=sa.Enum(*OLD_TYPES, name='task_type_enum'),
|
||||
existing_nullable=False,
|
||||
existing_server_default='note',
|
||||
)
|
||||
batch.drop_column('fields')
|
||||
batch.drop_column('at_close')
|
||||
batch.drop_column('in_loop')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""stations.vision_worker_url e vision_results.worker_url (#45)
|
||||
|
||||
stations.vision_worker_url: worker di visione della stazione (NULL = centrale).
|
||||
vision_results.worker_url: worker effettivamente chiamato per quel risultato.
|
||||
|
||||
Revision ID: 019_station_vision_worker
|
||||
Revises: 018_task_loop_results
|
||||
Create Date: 2026-10-11
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '019_station_vision_worker'
|
||||
down_revision: Union[str, None] = '018_task_loop_results'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.add_column(sa.Column('vision_worker_url', sa.String(500), nullable=True))
|
||||
with op.batch_alter_table('vision_results') as batch:
|
||||
batch.add_column(sa.Column('worker_url', sa.String(500), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('vision_results') as batch:
|
||||
batch.drop_column('worker_url')
|
||||
with op.batch_alter_table('stations') as batch:
|
||||
batch.drop_column('vision_worker_url')
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
from src.backend.models.api.tipi import UtcDateTime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
|
||||
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
if not value.lower().startswith(("http://", "https://")):
|
||||
raise ValueError("vision_worker_url must start with http:// or https://")
|
||||
return value.rstrip("/")
|
||||
|
||||
|
||||
class StationCreate(BaseModel):
|
||||
@@ -11,6 +23,10 @@ class StationCreate(BaseModel):
|
||||
location: Optional[str] = Field(default=None, max_length=255)
|
||||
notes: Optional[str] = None
|
||||
active: bool = True
|
||||
# worker di visione della stazione (#45); vuoto = centrale
|
||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||
|
||||
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||
|
||||
|
||||
class StationUpdate(BaseModel):
|
||||
@@ -18,6 +34,10 @@ class StationUpdate(BaseModel):
|
||||
location: Optional[str] = Field(default=None, max_length=255)
|
||||
notes: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
# worker di visione della stazione (#45); "" -> None riporta al centrale
|
||||
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
|
||||
|
||||
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
|
||||
|
||||
|
||||
class StationResponse(BaseModel):
|
||||
@@ -28,6 +48,7 @@ class StationResponse(BaseModel):
|
||||
location: Optional[str]
|
||||
notes: Optional[str]
|
||||
active: bool
|
||||
vision_worker_url: Optional[str] = None # #45
|
||||
created_by: int
|
||||
created_at: UtcDateTime
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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}$")
|
||||
|
||||
|
||||
@@ -16,6 +16,34 @@ class VisionExecuteResponse(BaseModel):
|
||||
vision_result_id: int
|
||||
engine_version: str
|
||||
measurements: list[VisionMeasurementResult]
|
||||
# Esito VisionSuite (informativo: il verdetto lo danno utl/ltl), overlay
|
||||
# in px immagine, URL dell'immagine salvata e strumenti falliti (#46).
|
||||
outcome: str | None = None
|
||||
overlay: list[dict] = []
|
||||
image_url: str | None = None
|
||||
failures: list[dict] = []
|
||||
|
||||
|
||||
class VisionRecipeOutput(BaseModel):
|
||||
"""One numeric output a recipe offers to the quotes."""
|
||||
key: str
|
||||
quantity: str | None = None
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
class VisionRecipeUploadResponse(BaseModel):
|
||||
task_id: int
|
||||
recipe_dir: str
|
||||
files: list[str]
|
||||
outputs: list[VisionRecipeOutput]
|
||||
|
||||
|
||||
class VisionRecipeResponse(BaseModel):
|
||||
task_id: int
|
||||
recipe_dir: str | None = None
|
||||
recipe: dict | None = None
|
||||
files: list[str]
|
||||
outputs: list[VisionRecipeOutput]
|
||||
|
||||
|
||||
class VisionReferenceImageResponse(BaseModel):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,6 +25,8 @@ class Station(Base):
|
||||
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
# URL del worker di visione della stazione (#45); NULL = worker centrale
|
||||
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||
Boolean, DECIMAL, Enum, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -15,10 +15,21 @@ if TYPE_CHECKING:
|
||||
# What a task is, declared by whoever writes the recipe instead of guessed from its
|
||||
# contents. The last two are not built yet; they are in the enum from the start
|
||||
# because widening a MySQL enum later is an ALTER on a live table.
|
||||
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
|
||||
# 'check' = Controllo OK/NOK, senza quote (#54).
|
||||
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure", "check")
|
||||
|
||||
# Types the operator screen treats as "there are quotes to take here".
|
||||
MEASURING_TASK_TYPES = ("measure", "camera_measure")
|
||||
# dxf_compare produce quote come camera_measure (#46)
|
||||
MEASURING_TASK_TYPES = ("measure", "camera_measure", "dxf_compare")
|
||||
|
||||
# Tipi che entrano nel ciclo di produzione se in_loop non e' dichiarato (#53):
|
||||
# le misure di sempre piu' il controllo OK/NOK.
|
||||
DEFAULT_LOOP_TASK_TYPES = MEASURING_TASK_TYPES + ("check",)
|
||||
|
||||
|
||||
def _default_in_loop(context) -> bool:
|
||||
"""Default alla creazione di in_loop: dipende dal tipo del task (#53)."""
|
||||
return context.get_current_parameters().get("task_type", "note") in DEFAULT_LOOP_TASK_TYPES
|
||||
|
||||
|
||||
class RecipeTask(Base):
|
||||
@@ -51,6 +62,20 @@ class RecipeTask(Base):
|
||||
# server routes it and never interprets it, which is what keeps VisionSuite
|
||||
# out of this container.
|
||||
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
|
||||
# meta della ricetta VisionSuite v2 (#46).
|
||||
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
|
||||
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
|
||||
in_loop: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=_default_in_loop, server_default="0"
|
||||
)
|
||||
# #53: task 'note' compilato alla fine produzione, prima della modale del capoturno.
|
||||
at_close: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
# #55: campi extra del task 'note' ([{key,label,type,default,attachment}]).
|
||||
fields: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""TaskResult: l'esito registrato da un task 'check' (OK/NOK) o 'note' (#54 #55)."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, JSON, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.backend.database import Base
|
||||
|
||||
TASK_OUTCOMES = ("ok", "nok", "done")
|
||||
|
||||
|
||||
class TaskResult(Base):
|
||||
__tablename__ = "task_results"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
# Copiata dal task: la versione in cui l'esito e' stato dato non cambia piu'.
|
||||
version_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("recipe_versions.id"), nullable=False, index=True
|
||||
)
|
||||
production_run_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("production_runs.id", ondelete="SET NULL"),
|
||||
nullable=True, index=True,
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=False
|
||||
)
|
||||
outcome: Mapped[str] = mapped_column(
|
||||
Enum(*TASK_OUTCOMES, name="task_outcome_enum"), nullable=False
|
||||
)
|
||||
note: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
# {chiave del campo: valore stringa}; allegati: [{key, path, name}]
|
||||
values: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
attachments: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = ({"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"},)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TaskResult task={self.task_id} outcome={self.outcome}>"
|
||||
@@ -22,6 +22,13 @@ class User(Base):
|
||||
# Admin flag separate from roles
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
# PIN a 4 cifre (#59): solo l'hash; contatore errori e fine blocco per il lockout
|
||||
pin_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
pin_failed_count: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
pin_locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# User preferences
|
||||
language_pref: Mapped[str] = mapped_column(
|
||||
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
||||
@@ -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 [])
|
||||
|
||||
@@ -26,6 +26,8 @@ class VisionResult(Base):
|
||||
Integer, ForeignKey("stations.id"), nullable=True
|
||||
)
|
||||
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
# URL del worker effettivamente chiamato (#45), copiato: la stazione puo' cambiarlo dopo
|
||||
worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# Copied, not referenced: a calibration redone six months from now must not
|
||||
# rewrite what a measurement already taken means. Same for the graph.
|
||||
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -115,6 +115,12 @@ async def _copy_tasks_to_version(
|
||||
# a camera_measure task unable to execute from the moment a
|
||||
# recipe with measurements is next edited.
|
||||
vision_json=task.vision_json,
|
||||
# Cartella immutabile: basta il riferimento (#46).
|
||||
vision_recipe_dir=task.vision_recipe_dir,
|
||||
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
|
||||
in_loop=task.in_loop,
|
||||
at_close=task.at_close,
|
||||
fields=task.fields,
|
||||
)
|
||||
db.add(new_task)
|
||||
await db.flush() # get new_task.id
|
||||
@@ -253,6 +259,78 @@ async def create_recipe(
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def duplicate_recipe(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
new_code: str,
|
||||
new_name: str,
|
||||
user: User,
|
||||
) -> Recipe:
|
||||
"""Duplicate a recipe (#52): new code/name, v1 with the current version's tasks.
|
||||
|
||||
The source recipe is left untouched.
|
||||
"""
|
||||
source = await _get_recipe_or_404(db, recipe_id)
|
||||
|
||||
existing = await db.execute(select(Recipe).where(Recipe.code == new_code))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Recipe code '{new_code}' already exists",
|
||||
)
|
||||
|
||||
# Same header fields create_recipe persists, taken from the source recipe
|
||||
recipe = Recipe(
|
||||
code=new_code,
|
||||
name=new_name,
|
||||
description=source.description,
|
||||
image_path=source.image_path,
|
||||
measurement_interval_minutes=source.measurement_interval_minutes,
|
||||
requires_lot=source.requires_lot,
|
||||
requires_serial=source.requires_serial,
|
||||
allow_manual_input=source.allow_manual_input,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(recipe)
|
||||
await db.flush()
|
||||
|
||||
version = RecipeVersion(
|
||||
recipe_id=recipe.id,
|
||||
version_number=1,
|
||||
is_current=True,
|
||||
created_by=user.id,
|
||||
change_notes=f"Duplicated from {source.code}",
|
||||
)
|
||||
db.add(version)
|
||||
await db.flush()
|
||||
|
||||
# Reuse the existing deep copy of tasks/subtasks from the current version
|
||||
source_version = await _get_current_version(db, source.id)
|
||||
if source_version is not None:
|
||||
await _copy_tasks_to_version(db, source_version, version)
|
||||
|
||||
await _write_audit(
|
||||
db,
|
||||
recipe_id=recipe.id,
|
||||
old_version_id=None,
|
||||
new_version_id=version.id,
|
||||
changed_by=user.id,
|
||||
change_type="CREATE",
|
||||
change_reason=f"Duplicated from recipe {source.code}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(Recipe)
|
||||
.where(Recipe.id == recipe.id)
|
||||
.options(
|
||||
selectinload(Recipe.versions)
|
||||
.selectinload(RecipeVersion.tasks)
|
||||
.selectinload(RecipeTask.subtasks)
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def create_new_version(
|
||||
db: AsyncSession,
|
||||
recipe_id: int,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -31,6 +31,7 @@ async def create_station(
|
||||
location=data.location,
|
||||
notes=data.notes,
|
||||
active=data.active,
|
||||
vision_worker_url=data.vision_worker_url, # #45
|
||||
created_by=creator.id,
|
||||
)
|
||||
db.add(station)
|
||||
|
||||
@@ -5,6 +5,7 @@ to the worker over HTTP and stays ignorant of how a number was produced.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
@@ -18,6 +19,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
from src.backend.services import measurement_service
|
||||
@@ -46,7 +48,14 @@ def _is_measurable(value: object) -> bool:
|
||||
)
|
||||
|
||||
|
||||
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def _call_worker(
|
||||
image_bytes: bytes,
|
||||
graph: dict,
|
||||
*,
|
||||
recipe_dir: str | None = None,
|
||||
calibration: dict | None = None,
|
||||
worker_url: str | None = None,
|
||||
) -> dict:
|
||||
"""The one place that talks to the worker; the tests replace it.
|
||||
|
||||
The worker's fault must not become the API's fault (spec's edge-case
|
||||
@@ -60,22 +69,31 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||
is not the worker's JSON contract must not turn into a bare KeyError
|
||||
further down.
|
||||
"""
|
||||
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
|
||||
# si mandano, il worker usa i suoi default.
|
||||
data = {"graph": json.dumps(graph)}
|
||||
# worker della stazione (#45) o, se manca, il centrale; mai un ripiego dall'uno all'altro
|
||||
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||
if recipe_dir:
|
||||
data["recipe_dir"] = recipe_dir
|
||||
if calibration:
|
||||
data["calibration"] = json.dumps(calibration)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.vision_worker_url}/run",
|
||||
f"{base}/run",
|
||||
files={"image": ("frame.png", image_bytes, "image/png")},
|
||||
data={"graph": json.dumps(graph)},
|
||||
data=data,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f"the vision worker did not answer in time: {exc}",
|
||||
detail=f"the vision worker did not answer in time: {exc} ({base})", # #45
|
||||
) from exc
|
||||
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker is unreachable: {exc}",
|
||||
detail=f"the vision worker is unreachable: {exc} ({base})", # #45
|
||||
) from exc
|
||||
|
||||
# The worker already gave this fault a name - forward its status and
|
||||
@@ -122,6 +140,188 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
||||
return report
|
||||
|
||||
|
||||
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
||||
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
||||
|
||||
Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir
|
||||
refused) is folded into ok=False; transport faults become 502/504 as in
|
||||
`_call_worker`. Replaced by the tests.
|
||||
"""
|
||||
data = {"graph": json.dumps(graph)}
|
||||
if recipe_dir:
|
||||
data["recipe_dir"] = recipe_dir
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.vision_worker_url}/validate", data=data,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail=f"the vision worker did not answer in time: {exc}",
|
||||
) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker is unreachable: {exc}",
|
||||
) from exc
|
||||
if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT:
|
||||
try:
|
||||
detail = response.json().get("detail", response.text)
|
||||
except ValueError:
|
||||
detail = response.text
|
||||
errors = detail if isinstance(detail, list) else [str(detail)]
|
||||
return {"ok": False, "errors": [str(e) for e in errors], "outputs": []}
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker refused the job: {response.text}",
|
||||
)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: not valid JSON",
|
||||
) from exc
|
||||
if not isinstance(body, dict) or "ok" not in body:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: missing ok",
|
||||
)
|
||||
body.setdefault("errors", [])
|
||||
body.setdefault("outputs", [])
|
||||
return body
|
||||
|
||||
|
||||
# Task types that run a vision graph (#46).
|
||||
VISION_TASK_TYPES = ("camera_measure", "dxf_compare")
|
||||
|
||||
|
||||
def _safe_join(root: Path, relative: str) -> Path:
|
||||
"""root/relative, refusing absolute paths, '..' and anything leaving root."""
|
||||
candidate = Path(relative)
|
||||
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
|
||||
raise ValueError(f"unsafe path: {relative}")
|
||||
full = (root / candidate).resolve()
|
||||
if not full.is_relative_to(root.resolve()):
|
||||
raise ValueError(f"unsafe path: {relative}")
|
||||
return full
|
||||
|
||||
|
||||
def collect_meta_paths(node: object) -> list[str]:
|
||||
"""Every `path` string found in any dict under the recipe's `meta`."""
|
||||
found: list[str] = []
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if key == "path" and isinstance(value, str):
|
||||
found.append(value)
|
||||
else:
|
||||
found.extend(collect_meta_paths(value))
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
found.extend(collect_meta_paths(item))
|
||||
return found
|
||||
|
||||
|
||||
def match_recipe_files(
|
||||
recipe: dict, uploads: list[tuple[str, bytes]],
|
||||
) -> tuple[dict[str, bytes], list[str]]:
|
||||
"""Pair each uploaded file with a meta path by base name (#46).
|
||||
|
||||
Returns ({relative path: bytes}, errors). Errors list every upload that
|
||||
has no name or matches no meta path, and every unsafe meta path. A meta
|
||||
path left without a file is not an error here: /validate decides.
|
||||
"""
|
||||
paths = collect_meta_paths(recipe.get("meta", {}))
|
||||
by_name: dict[str, str] = {}
|
||||
for p in paths:
|
||||
by_name.setdefault(Path(p.replace("\\", "/")).name, p)
|
||||
placed: dict[str, bytes] = {}
|
||||
errors: list[str] = []
|
||||
for name, content in uploads:
|
||||
if not name:
|
||||
errors.append("a file has no name")
|
||||
continue
|
||||
target = by_name.get(Path(name.replace("\\", "/")).name)
|
||||
if target is None:
|
||||
errors.append(f"file {name!r} matches no path in the recipe's meta")
|
||||
continue
|
||||
try:
|
||||
_safe_join(Path("."), target)
|
||||
except ValueError:
|
||||
errors.append(f"meta path {target!r} is not allowed")
|
||||
continue
|
||||
placed[target] = content
|
||||
return placed, errors
|
||||
|
||||
|
||||
def store_recipe_folder(
|
||||
task_id: int, recipe: dict, files: dict[str, bytes],
|
||||
) -> str:
|
||||
"""Write the immutable recipe folder; return its path relative to uploads.
|
||||
|
||||
Folder name = first 16 hex of the recipe's sha256. If it already exists it
|
||||
is left untouched (immutable: other task versions may reference it).
|
||||
"""
|
||||
canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":"))
|
||||
# Il digest copre anche i file (path ordinato + sha256 del contenuto): la
|
||||
# stessa ricetta con file diversi (es. primo upload senza il DXF) deve dare
|
||||
# una cartella diversa, altrimenti quella incompleta resterebbe per sempre
|
||||
# perche' immutabile (#46).
|
||||
h = hashlib.sha256(canonical.encode("utf-8"))
|
||||
for rel in sorted(files):
|
||||
h.update(b"\0" + rel.encode("utf-8") + b"\0")
|
||||
h.update(hashlib.sha256(files[rel]).digest())
|
||||
digest = h.hexdigest()[:16]
|
||||
relative = f"vision/recipes/{task_id}/{digest}"
|
||||
folder = settings.upload_path / relative
|
||||
if folder.exists():
|
||||
return relative
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / "recipe.json").write_text(
|
||||
json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8",
|
||||
)
|
||||
for rel, content in files.items():
|
||||
target = _safe_join(folder, rel)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
return relative
|
||||
|
||||
|
||||
def list_recipe_files(recipe_dir: str | None) -> list[str]:
|
||||
"""Files of a recipe folder, relative to it (recipe.json excluded)."""
|
||||
if not recipe_dir:
|
||||
return []
|
||||
try:
|
||||
folder = _safe_join(settings.upload_path, recipe_dir)
|
||||
except ValueError:
|
||||
return []
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
p.relative_to(folder).as_posix()
|
||||
for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json"
|
||||
)
|
||||
|
||||
|
||||
def save_result_files(
|
||||
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
|
||||
) -> tuple[str, str]:
|
||||
"""Save the executed image and its overlay JSON side by side (#46).
|
||||
|
||||
Returns (image_path, overlay_path), both relative to uploads.
|
||||
"""
|
||||
stem = uuid4().hex
|
||||
base = Path("vision") / "results" / str(task_id)
|
||||
(settings.upload_path / base).mkdir(parents=True, exist_ok=True)
|
||||
image_rel = (base / f"{stem}.{image_ext}").as_posix()
|
||||
overlay_rel = (base / f"{stem}.overlay.json").as_posix()
|
||||
(settings.upload_path / image_rel).write_bytes(image_bytes)
|
||||
(settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8")
|
||||
return image_rel, overlay_rel
|
||||
|
||||
|
||||
async def execute_task(
|
||||
db: AsyncSession,
|
||||
task: RecipeTask,
|
||||
@@ -132,8 +332,9 @@ async def execute_task(
|
||||
lot_number: str | None = None,
|
||||
serial_number: str | None = None,
|
||||
production_run_id: int | None = None,
|
||||
) -> tuple[VisionResult, list[Measurement]]:
|
||||
if task.task_type != "camera_measure":
|
||||
image_ext: str = "png",
|
||||
) -> tuple[VisionResult, list[Measurement], dict]:
|
||||
if task.task_type not in VISION_TASK_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"task type {task.task_type!r} is not a camera measurement",
|
||||
@@ -155,12 +356,20 @@ async def execute_task(
|
||||
detail="this recipe is not active",
|
||||
)
|
||||
|
||||
run = None
|
||||
if production_run_id is not None:
|
||||
# 404 if the run does not exist, 422 if it belongs to another recipe,
|
||||
# 409 if it is closed or paused - same rule a manual reading follows.
|
||||
await measurement_service.validate_run_for_version(
|
||||
run = await measurement_service.validate_run_for_version(
|
||||
db, production_run_id, version_id,
|
||||
)
|
||||
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||
station_url = None
|
||||
if run is not None:
|
||||
station = (await db.execute(
|
||||
select(Station).where(Station.id == run.station_id)
|
||||
)).scalar_one_or_none()
|
||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
@@ -210,7 +419,12 @@ async def execute_task(
|
||||
),
|
||||
)
|
||||
|
||||
report = await _call_worker(image_bytes, task.vision_json)
|
||||
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
|
||||
# passa `calibration`, la ricetta porta la sua (#46).
|
||||
report = await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
worker_url=station_url, # #45
|
||||
)
|
||||
outputs = report["outputs"]
|
||||
|
||||
# Every quote or none. A half-measured piece read as a whole one is worse
|
||||
@@ -231,10 +445,17 @@ async def execute_task(
|
||||
),
|
||||
)
|
||||
|
||||
overlay = report.get("overlay") or []
|
||||
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
|
||||
result = VisionResult(
|
||||
task_id=task.id,
|
||||
image_path=image_path,
|
||||
overlay_path=overlay_path,
|
||||
engine_version=report["engine_version"],
|
||||
executed_on="server",
|
||||
# #45: dove e' girato davvero, e quale worker (copiato come lo snapshot)
|
||||
executed_on="station" if station_url else "server",
|
||||
station_id=run.station_id if run is not None else None,
|
||||
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||
graph_snapshot=task.vision_json,
|
||||
duration_ms=report.get("duration_ms"),
|
||||
)
|
||||
@@ -257,7 +478,7 @@ async def execute_task(
|
||||
enforce_tolerance_gate=False,
|
||||
))
|
||||
|
||||
return result, saved
|
||||
return result, saved, report
|
||||
|
||||
|
||||
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
||||
@@ -377,4 +598,6 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="this task carries no vision graph",
|
||||
)
|
||||
return await _call_worker(image_bytes, task.vision_json)
|
||||
return await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,10 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.operations import Operations
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.config import Config
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import create_engine, inspect as sa_inspect, text
|
||||
|
||||
from src.backend.config import Settings
|
||||
@@ -131,3 +134,190 @@ class TestApiKeyHashMigration:
|
||||
|
||||
command.upgrade(cfg, "head") # and up again
|
||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||
|
||||
def test_015_on_create_all_schema(self, sqlite_database_url):
|
||||
"""DB nato da create_all: users.api_key ha solo l'indice unique
|
||||
ix_users_api_key, nessun vincolo uq_users_api_key (#71)."""
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
ctx = MigrationContext.configure(conn)
|
||||
ops = Operations(ctx)
|
||||
with ops.batch_alter_table("users") as batch:
|
||||
batch.drop_index("ix_users_api_key")
|
||||
batch.drop_constraint("uq_users_api_key", type_="unique")
|
||||
ops.create_index("ix_users_api_key", "users", ["api_key"], unique=True)
|
||||
engine.dispose()
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert not insp.get_unique_constraints("users") or "uq_users_api_key" not in {
|
||||
u["name"] for u in insp.get_unique_constraints("users")
|
||||
}
|
||||
|
||||
command.upgrade(cfg, "head") # non deve sollevare
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" in insp.get_table_names()
|
||||
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||
|
||||
def test_015_resumes_after_partial_run(self, sqlite_database_url):
|
||||
"""015 a meta' (MySQL, DDL non transazionale): api_keys creata e
|
||||
ix_users_api_key tolto, alembic_version ancora 014 (#71)."""
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "014_vision_fk_ondelete")
|
||||
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
ops = Operations(MigrationContext.configure(conn))
|
||||
ops.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("key_hash", sa.String(64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"], ["users.id"],
|
||||
name="fk_api_keys_user_id", ondelete="CASCADE",
|
||||
),
|
||||
)
|
||||
ops.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
|
||||
ops.drop_index("ix_users_api_key", table_name="users")
|
||||
engine.dispose()
|
||||
|
||||
command.upgrade(cfg, "head") # non deve sollevare
|
||||
|
||||
insp = _inspect(db_path)
|
||||
assert "api_keys" in insp.get_table_names()
|
||||
assert {i["name"] for i in insp.get_indexes("api_keys")} >= {
|
||||
"ix_api_keys_key_hash", "ix_api_keys_user_id",
|
||||
}
|
||||
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||
|
||||
|
||||
class TestVisionRecipeDirMigration:
|
||||
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
||||
|
||||
def test_016_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def cols():
|
||||
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert "vision_recipe_dir" in cols()
|
||||
command.downgrade(cfg, "015_api_key_hash")
|
||||
assert "vision_recipe_dir" not in cols()
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert "vision_recipe_dir" in cols()
|
||||
|
||||
|
||||
class TestUserPinMigration:
|
||||
"""017_user_pin: users.pin_hash / pin_failed_count / pin_locked_until, su e giu (#59)."""
|
||||
|
||||
def test_017_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
new = {"pin_hash", "pin_failed_count", "pin_locked_until"}
|
||||
|
||||
def cols():
|
||||
return {c["name"] for c in _inspect(db_path).get_columns("users")}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert new <= cols()
|
||||
command.downgrade(cfg, "016_vision_recipe_dir")
|
||||
assert not (new & cols())
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert new <= cols()
|
||||
|
||||
|
||||
_SEED_USER_RECIPE_VERSION = [
|
||||
"INSERT INTO users (username, password_hash, display_name, roles, is_admin, active,"
|
||||
" language_pref, theme_pref) VALUES ('u', 'x', 'U', '[]', 1, 1, 'en', 'light')",
|
||||
"INSERT INTO recipes (code, name, active, created_by) VALUES ('R', 'R', 1, 1)",
|
||||
"INSERT INTO recipe_versions (recipe_id, version_number, is_current, created_by)"
|
||||
" VALUES (1, 1, 1, 1)",
|
||||
]
|
||||
|
||||
|
||||
class TestTaskLoopResultsMigration:
|
||||
"""018_task_loop_results: in_loop/at_close/fields, tipo check, task_results (#53 #54 #55)."""
|
||||
|
||||
def test_018_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def cols():
|
||||
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert {"in_loop", "at_close", "fields"} <= cols()
|
||||
assert "task_results" in _inspect(db_path).get_table_names()
|
||||
command.downgrade(cfg, "017_user_pin")
|
||||
assert not ({"in_loop", "at_close", "fields"} & cols())
|
||||
assert "task_results" not in _inspect(db_path).get_table_names()
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
|
||||
def test_018_marks_existing_measure_tasks_in_loop(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "017_user_pin")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
for stmt in _SEED_USER_RECIPE_VERSION:
|
||||
conn.execute(text(stmt))
|
||||
for i, t in enumerate(("measure", "camera_measure", "dxf_compare", "note", "drawing")):
|
||||
conn.execute(text(
|
||||
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
|
||||
f" VALUES (1, {i}, '{t}', '{t}')"))
|
||||
engine.dispose()
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.connect() as conn:
|
||||
rows = dict(conn.execute(text("SELECT task_type, in_loop FROM recipe_tasks")).all())
|
||||
engine.dispose()
|
||||
assert rows == {"measure": 1, "camera_measure": 1, "dxf_compare": 1, "note": 0, "drawing": 0}
|
||||
|
||||
def test_018_downgrade_refused_when_a_check_task_exists(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
command.upgrade(cfg, "head")
|
||||
engine = create_engine(f"sqlite:///{db_path}")
|
||||
with engine.begin() as conn:
|
||||
for stmt in _SEED_USER_RECIPE_VERSION:
|
||||
conn.execute(text(stmt))
|
||||
conn.execute(text(
|
||||
"INSERT INTO recipe_tasks (version_id, order_index, task_type, title)"
|
||||
" VALUES (1, 0, 'check', 'c')"))
|
||||
engine.dispose()
|
||||
|
||||
with pytest.raises(RuntimeError, match="check"):
|
||||
command.downgrade(cfg, "017_user_pin")
|
||||
|
||||
|
||||
class TestStationVisionWorkerMigration:
|
||||
"""019_station_vision_worker: stations.vision_worker_url e vision_results.worker_url, su e giu (#45)."""
|
||||
|
||||
def test_019_upgrade_and_downgrade(self, sqlite_database_url):
|
||||
db_path, _ = sqlite_database_url
|
||||
cfg = _alembic_config()
|
||||
|
||||
def cols(table):
|
||||
return {c["name"] for c in _inspect(db_path).get_columns(table)}
|
||||
|
||||
command.upgrade(cfg, "head")
|
||||
assert "vision_worker_url" in cols("stations")
|
||||
assert "worker_url" in cols("vision_results")
|
||||
command.downgrade(cfg, "018_task_loop_results")
|
||||
assert "vision_worker_url" not in cols("stations")
|
||||
assert "worker_url" not in cols("vision_results")
|
||||
command.upgrade(cfg, "head") # e di nuovo su
|
||||
assert "vision_worker_url" in cols("stations")
|
||||
assert "worker_url" in cols("vision_results")
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""GET /api/production-runs/open - live monitor of the open runs (#57)."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.tests.conftest import _create_user, auth_headers, create_test_recipe
|
||||
|
||||
|
||||
async def _run(db, user, code, status="running", overdue=False, open_=True):
|
||||
"""A station plus a run on a fresh recipe (limits ltl 9.5 / utl 10.5)."""
|
||||
station = Station(code=f"ST-{code}", name=f"Linea {code}", active=True, created_by=user.id)
|
||||
db.add(station)
|
||||
recipe = await create_test_recipe(db, user_id=user.id, code=f"REC-{code}")
|
||||
await db.flush()
|
||||
version = recipe.versions[0]
|
||||
now = datetime.utcnow()
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=user.id, status=status if open_ else "closed",
|
||||
started_at=now - timedelta(hours=1),
|
||||
measurement_interval_minutes=30,
|
||||
next_measurement_at=now + (timedelta(minutes=-5) if overdue else timedelta(minutes=20)),
|
||||
cycle_count=3,
|
||||
active_station_id=station.id if open_ else None,
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
db.add(ProductionEvent(run_id=run.id, event_type="start", user_id=user.id))
|
||||
sub = (await db.execute(
|
||||
select(RecipeSubtask).join(RecipeTask, RecipeTask.id == RecipeSubtask.task_id)
|
||||
.where(RecipeTask.version_id == version.id)
|
||||
)).scalars().first()
|
||||
return run, sub, version
|
||||
|
||||
|
||||
def _m(run, sub, version, user, value, pf, minutes=0):
|
||||
return Measurement(
|
||||
subtask_id=sub.id, version_id=version.id, measured_by=user.id, value=value,
|
||||
pass_fail=pf, production_run_id=run.id,
|
||||
measured_at=datetime.utcnow() - timedelta(minutes=minutes),
|
||||
)
|
||||
|
||||
|
||||
async def test_open_requires_high_authority(client, db_session, measurement_tec_user):
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(measurement_tec_user))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_open_lists_only_open_runs_with_means(client, db_session, admin_user):
|
||||
sup = await _create_user(db_session, "capo", roles=["Supervisor"])
|
||||
r1, s1, v1 = await _run(db_session, admin_user, "A", overdue=True)
|
||||
r2, s2, v2 = await _run(db_session, admin_user, "B", status="paused")
|
||||
await _run(db_session, admin_user, "C", open_=False)
|
||||
# Run A: mean 10.0 (inside), run B: mean 10.8 (outside, one fail).
|
||||
for i, (val, pf) in enumerate([(9.9, "pass"), (10.1, "pass")]):
|
||||
db_session.add(_m(r1, s1, v1, admin_user, val, pf, i))
|
||||
for i, (val, pf) in enumerate([(10.6, "fail"), (11.0, "fail"), (10.8, "warning")]):
|
||||
db_session.add(_m(r2, s2, v2, admin_user, val, pf, i))
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(sup))
|
||||
assert resp.status_code == 200
|
||||
body = {r["station_code"]: r for r in resp.json()}
|
||||
assert set(body) == {"ST-A", "ST-B"} # closed run C is absent
|
||||
a, b = body["ST-A"], body["ST-B"]
|
||||
assert a["overdue"] is True and a["seconds_to_next_measurement"] < 0
|
||||
assert b["overdue"] is False and b["status"] == "paused"
|
||||
assert a["duration_seconds"] >= 3600 and a["cycle_count"] == 3
|
||||
assert a["last_event"]["event_type"] == "start"
|
||||
assert a["pass_count"] == 2 and a["fail_count"] == 0
|
||||
assert a["subtasks"][0]["mean"] == 10.0 and a["subtasks"][0]["in_tolerance"] is True
|
||||
assert b["fail_count"] == 2 and b["warning_count"] == 1
|
||||
assert round(b["subtasks"][0]["mean"], 3) == 10.8
|
||||
assert b["subtasks"][0]["in_tolerance"] is False
|
||||
|
||||
|
||||
async def test_open_empty_for_admin(client, admin_user):
|
||||
resp = await client.get("/api/production-runs/open", headers=auth_headers(admin_user))
|
||||
assert resp.status_code == 200 and resp.json() == []
|
||||
@@ -0,0 +1,175 @@
|
||||
"""PIN a 4 cifre: login rapido, blocco, capoturno via PIN (#59)."""
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import auth_service
|
||||
from src.backend.tests.conftest import _create_user, auth_headers
|
||||
from src.backend.tests.test_production_runs import (
|
||||
_open, _recipe_with_interval, _station,
|
||||
)
|
||||
from src.backend.utils.tempo import utcnow
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _alza_limite_login(monkeypatch):
|
||||
# #65: login-pin conta nel bucket del login (limite al minuto); i test del blocco PIN
|
||||
# fanno piu tentativi di fila, quindi qui il limite si alza. Il middleware resta com'e.
|
||||
monkeypatch.setattr(settings, "rate_limit_login", 1000)
|
||||
|
||||
|
||||
async def _pin_user(db_session, username="operaio", pin="1234", **kw) -> User:
|
||||
user = await _create_user(db_session, username=username, **kw)
|
||||
await auth_service.set_user_pin(db_session, user, pin)
|
||||
await db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
async def _login_pin(client, user_id, pin):
|
||||
return await client.post("/api/auth/login-pin", json={"user_id": user_id, "pin": pin})
|
||||
|
||||
|
||||
async def test_login_pin_ok_same_shape_as_login(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
resp = await _login_pin(client, user.id, "1234")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["user"]["username"] == "operaio"
|
||||
assert data["user"]["has_pin"] is True
|
||||
assert data["api_key"]
|
||||
assert "pin_hash" not in data["user"]
|
||||
# la api_key vale davvero
|
||||
me = await client.get("/api/auth/me", headers={"X-API-Key": data["api_key"]})
|
||||
assert me.status_code == 200
|
||||
|
||||
|
||||
async def test_login_pin_wrong_is_401(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
assert (await _login_pin(client, user.id, "9999")).status_code == 401
|
||||
|
||||
|
||||
async def test_login_pin_format_422(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for bad in ("123", "12345", "12a4", ""):
|
||||
assert (await _login_pin(client, user.id, bad)).status_code == 422
|
||||
|
||||
|
||||
async def test_lock_after_five_errors_even_with_right_pin(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for _ in range(4):
|
||||
assert (await _login_pin(client, user.id, "0000")).status_code == 401
|
||||
resp = await _login_pin(client, user.id, "0000") # 5 errore: scatta il blocco
|
||||
assert resp.status_code == 423
|
||||
assert "locked until" in resp.json()["detail"]
|
||||
assert (await _login_pin(client, user.id, "1234")).status_code == 423
|
||||
|
||||
|
||||
async def test_lock_expires_and_counter_resets(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for _ in range(5):
|
||||
await _login_pin(client, user.id, "0000")
|
||||
row = (await db_session.execute(select(User).where(User.id == user.id))).scalar_one()
|
||||
await db_session.refresh(row)
|
||||
assert row.pin_locked_until > utcnow() + timedelta(minutes=14)
|
||||
# tempo simulato: il blocco e scaduto un secondo fa
|
||||
row.pin_locked_until = utcnow() - timedelta(seconds=1)
|
||||
await db_session.commit()
|
||||
assert (await _login_pin(client, user.id, "1234")).status_code == 200
|
||||
await db_session.refresh(row)
|
||||
assert row.pin_failed_count == 0 and row.pin_locked_until is None
|
||||
|
||||
|
||||
async def test_success_resets_failed_counter(client: AsyncClient, db_session):
|
||||
user = await _pin_user(db_session)
|
||||
for _ in range(3):
|
||||
await _login_pin(client, user.id, "0000")
|
||||
assert (await _login_pin(client, user.id, "1234")).status_code == 200
|
||||
for _ in range(4): # il contatore e ripartito da zero: 4 errori non bloccano
|
||||
assert (await _login_pin(client, user.id, "0000")).status_code == 401
|
||||
|
||||
|
||||
async def test_admin_cannot_have_pin_or_login_with_it(client: AsyncClient, admin_user, db_session):
|
||||
resp = await client.put(
|
||||
f"/api/users/{admin_user.id}/pin", headers=auth_headers(admin_user), json={"pin": "1234"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
# anche un admin con pin_hash scritto a mano non entra da login-pin
|
||||
admin_user.pin_hash = auth_service.hash_password("1234")
|
||||
await db_session.commit()
|
||||
assert (await _login_pin(client, admin_user.id, "1234")).status_code == 401
|
||||
listed = (await client.get("/api/auth/pin-users")).json()
|
||||
assert all(u["id"] != admin_user.id for u in listed)
|
||||
|
||||
|
||||
async def test_put_pin_set_and_remove(client: AsyncClient, admin_user, db_session):
|
||||
user = await _create_user(db_session, username="senzapin")
|
||||
await db_session.commit()
|
||||
h = auth_headers(admin_user)
|
||||
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "4321"})).status_code == 200
|
||||
assert (await _login_pin(client, user.id, "4321")).status_code == 200
|
||||
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "12"})).status_code == 422
|
||||
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": None})).status_code == 200
|
||||
assert (await _login_pin(client, user.id, "4321")).status_code == 401
|
||||
assert (await client.put("/api/users/99999/pin", headers=h, json={"pin": "1234"})).status_code == 404
|
||||
# non admin: 403
|
||||
assert (await client.put(
|
||||
f"/api/users/{user.id}/pin", headers=auth_headers(user), json={"pin": "1234"},
|
||||
)).status_code == 403
|
||||
|
||||
|
||||
async def test_pin_users_list(client: AsyncClient, db_session):
|
||||
a = await _pin_user(db_session, username="zeta", display_name="Zeta Rossi", pin="1111")
|
||||
b = await _pin_user(db_session, username="alfa", display_name="Alfa Bianchi", pin="2222")
|
||||
await _create_user(db_session, username="nopin")
|
||||
await _pin_user(db_session, username="inattivo", pin="3333", active=False)
|
||||
await db_session.commit()
|
||||
resp = await client.get("/api/auth/pin-users") # senza autenticazione
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert [u["display_name"] for u in data] == ["Alfa Bianchi", "Zeta Rossi"]
|
||||
assert all(set(u) == {"id", "display_name"} for u in data)
|
||||
assert {u["id"] for u in data} == {a.id, b.id}
|
||||
|
||||
|
||||
async def test_supervisor_via_pin(client: AsyncClient, measurement_tec_user, admin_user, db_session):
|
||||
station = await _station(db_session, admin_user.id, code="ST-PIN")
|
||||
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PIN")
|
||||
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
|
||||
capo = await _pin_user(db_session, username="capo-pin", pin="5555", roles=["Supervisor"])
|
||||
h = auth_headers(measurement_tec_user)
|
||||
|
||||
bad = await client.post(
|
||||
f"/api/production-runs/{run_id}/pause", headers=h,
|
||||
json={"supervisor_user_id": capo.id, "supervisor_pin": "0000"},
|
||||
)
|
||||
assert bad.status_code == 401
|
||||
ok = await client.post(
|
||||
f"/api/production-runs/{run_id}/pause", headers=h,
|
||||
json={"supervisor_user_id": capo.id, "supervisor_pin": "5555"},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
# nessuna coppia di credenziali: 422
|
||||
none = await client.post(f"/api/production-runs/{run_id}/resume", headers=h, json={})
|
||||
assert none.status_code == 422
|
||||
|
||||
|
||||
async def test_supervisor_pin_role_still_checked(db_session):
|
||||
plain = await _pin_user(db_session, username="non-capo", pin="6666", roles=["Maker"])
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await auth_service.authorise_supervisor(db_session, user_id=plain.id, pin="6666")
|
||||
assert e.value.status_code == 403
|
||||
|
||||
|
||||
async def test_supervisor_pin_lockout(db_session):
|
||||
capo = await _pin_user(db_session, username="capo-lock", pin="7777", roles=["Supervisor"])
|
||||
codes = []
|
||||
for pin in ["0000"] * 5 + ["7777"]:
|
||||
with pytest.raises(HTTPException) as e:
|
||||
await auth_service.authorise_supervisor(db_session, user_id=capo.id, pin=pin)
|
||||
codes.append(e.value.status_code)
|
||||
assert codes == [401, 401, 401, 401, 423, 423]
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -374,3 +374,59 @@ async def test_reset_unknown_station_is_404(client: AsyncClient, admin_user):
|
||||
"/api/stations/999999/recipes", headers=auth_headers(admin_user),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- vision_worker_url (#45) ---
|
||||
|
||||
async def test_station_vision_worker_url_roundtrip(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations",
|
||||
headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VW", "name": "VW", "vision_worker_url": " http://10.0.0.7:8100/ "},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
# spazi tolti e "/" finale tolto
|
||||
assert created.json()["vision_worker_url"] == "http://10.0.0.7:8100"
|
||||
|
||||
sid = created.json()["id"]
|
||||
updated = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"vision_worker_url": "https://w.example:9000"},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["vision_worker_url"] == "https://w.example:9000"
|
||||
|
||||
# stringa vuota = torna al worker centrale
|
||||
cleared = await client.put(
|
||||
f"/api/stations/{sid}", headers=auth_headers(admin_user),
|
||||
json={"vision_worker_url": ""},
|
||||
)
|
||||
assert cleared.status_code == 200
|
||||
assert cleared.json()["vision_worker_url"] is None
|
||||
|
||||
|
||||
async def test_station_vision_worker_url_defaults_to_null(client: AsyncClient, admin_user):
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VN", "name": "VN"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.json()["vision_worker_url"] is None
|
||||
|
||||
|
||||
async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin_user):
|
||||
resp = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VF", "name": "VF", "vision_worker_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
created = await client.post(
|
||||
"/api/stations", headers=auth_headers(admin_user),
|
||||
json={"code": "ST-VG", "name": "VG"},
|
||||
)
|
||||
resp = await client.put(
|
||||
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
|
||||
json={"vision_worker_url": "ftp://x"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@@ -211,3 +211,192 @@ class TestDateFilter:
|
||||
assert resp.status_code == 200
|
||||
# All measurements should be within this wide range
|
||||
assert resp.json()["total"] >= 2
|
||||
|
||||
|
||||
class TestRunFilter:
|
||||
"""#58: statistiche per sessione di produzione."""
|
||||
|
||||
async def _setup(self, db_session, user, metro):
|
||||
from datetime import datetime
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from sqlalchemy import select
|
||||
recipe = await create_test_recipe(db_session, user.id)
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().first()
|
||||
station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
runs = []
|
||||
for i, lot in enumerate(["L1", "L2"]):
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=user.id, status="closed", lot_number=lot,
|
||||
started_at=datetime(2026, 1, 1 + i),
|
||||
)
|
||||
db_session.add(run)
|
||||
runs.append(run)
|
||||
await db_session.commit()
|
||||
return recipe, runs
|
||||
|
||||
async def test_filter_by_run_and_unfiltered(
|
||||
self, client, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user)
|
||||
subtask_id, version_id = await _seed_measurements(
|
||||
client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2],
|
||||
)
|
||||
# Assegna le prime due misure al run 1, la terza al run 2.
|
||||
from sqlalchemy import select
|
||||
ms = (await db_session.execute(
|
||||
select(Measurement).order_by(Measurement.id)
|
||||
)).scalars().all()
|
||||
ms[0].production_run_id = runs[0].id
|
||||
ms[1].production_run_id = runs[0].id
|
||||
ms[2].production_run_id = runs[1].id
|
||||
await db_session.commit()
|
||||
|
||||
h = auth_headers(metrologist_user)
|
||||
r1 = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id, "production_run_id": runs[0].id})
|
||||
assert r1.json()["total"] == 2
|
||||
r2 = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id, "production_run_id": runs[1].id})
|
||||
assert r2.json()["total"] == 1
|
||||
r_all = await client.get("/api/statistics/summary", headers=h,
|
||||
params={"recipe_id": recipe.id})
|
||||
assert r_all.json()["total"] == 3
|
||||
|
||||
runs_resp = await client.get("/api/statistics/runs", headers=h,
|
||||
params={"recipe_id": recipe.id})
|
||||
assert runs_resp.status_code == 200
|
||||
data = runs_resp.json()
|
||||
assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima
|
||||
assert [r["measurement_count"] for r in data] == [1, 2]
|
||||
assert data[0]["lot_number"] == "L2"
|
||||
assert data[0]["station_name"] == "Stazione 58"
|
||||
assert data[0]["closed_at"] is None
|
||||
|
||||
async def test_runs_require_metrologist(
|
||||
self, client, measurement_tec_user, db_session,
|
||||
):
|
||||
resp = await client.get("/api/statistics/runs",
|
||||
headers=auth_headers(measurement_tec_user),
|
||||
params={"recipe_id": 1})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestRunTaskResults:
|
||||
"""#58: GET /api/statistics/runs/{id}/task-results."""
|
||||
|
||||
FIELDS = [
|
||||
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
|
||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||
]
|
||||
|
||||
async def _setup(self, client, db_session, maker_user, tec_user):
|
||||
from datetime import datetime
|
||||
from sqlalchemy import select
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task_result import TaskResult
|
||||
recipe = await create_test_recipe(db_session, maker_user.id, code="TR58")
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().first()
|
||||
db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id))
|
||||
await db_session.commit()
|
||||
station = (await db_session.execute(select(Station))).scalars().first()
|
||||
run = ProductionRun(
|
||||
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||
operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1),
|
||||
)
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
h = auth_headers(maker_user)
|
||||
check = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||
json={"title": "Controllo", "task_type": "check"},
|
||||
)).json()
|
||||
note = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||
json={"title": "Nota", "task_type": "note", "fields": self.FIELDS},
|
||||
)).json()
|
||||
# Inseriti fuori ordine: l'API deve ordinare per created_at.
|
||||
rows = [
|
||||
TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="done", note="ok finito",
|
||||
values={"peso": "12", "foto": "x.jpg"},
|
||||
attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}],
|
||||
created_at=datetime(2026, 1, 1, 10, 3)),
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="nok", note="graffio",
|
||||
created_at=datetime(2026, 1, 1, 10, 2)),
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
|
||||
user_id=tec_user.id, outcome="ok",
|
||||
created_at=datetime(2026, 1, 1, 10, 1)),
|
||||
# Un esito di un'altra sessione (nessuna) non deve comparire.
|
||||
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None,
|
||||
user_id=tec_user.id, outcome="ok",
|
||||
created_at=datetime(2026, 1, 1, 9, 0)),
|
||||
]
|
||||
db_session.add_all(rows)
|
||||
await db_session.commit()
|
||||
return recipe, run, check, note
|
||||
|
||||
async def test_counts_order_labels_attachments(
|
||||
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
_, run, check, note = await self._setup(
|
||||
client, db_session, maker_user, measurement_tec_user)
|
||||
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["run_id"] == run.id
|
||||
assert data["counts"] == {"ok": 1, "nok": 1, "done": 1}
|
||||
res = data["results"]
|
||||
assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente
|
||||
assert res[1]["note"] == "graffio"
|
||||
assert res[1]["task_title"] == "Controllo"
|
||||
assert res[1]["task_type"] == "check"
|
||||
assert res[1]["user_name"] == measurement_tec_user.display_name
|
||||
assert res[1]["values"] == [] and res[1]["attachments"] == []
|
||||
n = res[2]
|
||||
assert n["task_title"] == "Nota" and n["note"] == "ok finito"
|
||||
assert n["values"] == [
|
||||
{"key": "peso", "label": "Peso", "value": "12"},
|
||||
{"key": "foto", "label": "Foto", "value": "x.jpg"},
|
||||
]
|
||||
assert n["attachments"] == [
|
||||
{"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"},
|
||||
]
|
||||
|
||||
async def test_label_falls_back_to_key_when_field_removed(
|
||||
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
|
||||
):
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
_, run, _, note = await self._setup(
|
||||
client, db_session, maker_user, measurement_tec_user)
|
||||
task = await db_session.get(RecipeTask, note["id"])
|
||||
task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task
|
||||
await db_session.commit()
|
||||
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
n = resp.json()["results"][2]
|
||||
assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"}
|
||||
assert n["attachments"][0]["label"] == "foto"
|
||||
|
||||
async def test_unknown_run_is_404(self, client, metrologist_user):
|
||||
resp = await client.get("/api/statistics/runs/99999/task-results",
|
||||
headers=auth_headers(metrologist_user))
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_measurement_tec_is_403(self, client, measurement_tec_user):
|
||||
resp = await client.get("/api/statistics/runs/1/task-results",
|
||||
headers=auth_headers(measurement_tec_user))
|
||||
assert resp.status_code == 403
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Test di in_loop / at_close / fields e del tipo 'check' sui task (#53 #54 #55),
|
||||
di close_task_ids nella risposta del run e del bucket di rate limit del login PIN."""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.api.middleware.rate_limit import RateLimitMiddleware
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
FIELDS = [
|
||||
{"key": "peso", "label": "Peso", "type": "number", "default": "1.5", "attachment": False},
|
||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||
]
|
||||
|
||||
|
||||
async def _post_task(client, user, recipe_id, **body):
|
||||
return await client.post(
|
||||
f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user), json=body,
|
||||
)
|
||||
|
||||
|
||||
async def test_create_defaults_in_loop_from_type(client: AsyncClient, maker_user, db_session):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L1")
|
||||
await db_session.commit()
|
||||
expected = {"note": False, "drawing": False, "measure": True,
|
||||
"camera_measure": True, "dxf_compare": True, "check": True}
|
||||
for task_type, in_loop in expected.items():
|
||||
resp = await _post_task(client, maker_user, recipe.id, title=task_type, task_type=task_type)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["in_loop"] is in_loop, task_type
|
||||
assert body["at_close"] is False
|
||||
assert body["fields"] is None
|
||||
|
||||
|
||||
async def test_create_explicit_in_loop_and_note_fields(client: AsyncClient, maker_user, db_session):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L2")
|
||||
await db_session.commit()
|
||||
resp = await _post_task(
|
||||
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||
in_loop=True, at_close=True, fields=FIELDS,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["in_loop"] is True and body["at_close"] is True
|
||||
assert body["fields"] == FIELDS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task_type", ["measure", "check", "drawing"])
|
||||
async def test_create_at_close_or_fields_rejected_for_other_types(
|
||||
client: AsyncClient, maker_user, db_session, task_type,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=f"REC-L3{task_type}")
|
||||
await db_session.commit()
|
||||
resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, at_close=True)
|
||||
assert resp.status_code == 422
|
||||
assert isinstance(resp.json()["detail"], str)
|
||||
resp = await _post_task(client, maker_user, recipe.id, title="x", task_type=task_type, fields=FIELDS)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
[{"key": "A b", "label": "x", "type": "text"}], # chiave non slug
|
||||
[{"key": "k", "label": "", "type": "text"}], # etichetta vuota
|
||||
[{"key": "k", "label": "x", "type": "date"}], # tipo ignoto
|
||||
[{"key": "k", "label": "x", "type": "text"}, {"key": "k", "label": "y", "type": "text"}], # duplicata
|
||||
[{"key": "k", "label": "x", "type": "number", "default": "abc"}], # default non numerico
|
||||
[{"key": f"k{i}", "label": "x", "type": "text"} for i in range(21)], # troppi
|
||||
])
|
||||
async def test_create_invalid_fields_rejected(client: AsyncClient, maker_user, db_session, bad):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L4")
|
||||
await db_session.commit()
|
||||
resp = await _post_task(client, maker_user, recipe.id, title="n", task_type="note", fields=bad)
|
||||
assert resp.status_code == 422
|
||||
assert isinstance(resp.json()["detail"], str)
|
||||
|
||||
|
||||
async def test_update_fields_null_clears_and_type_rules(client: AsyncClient, maker_user, db_session):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L5")
|
||||
await db_session.commit()
|
||||
created = (await _post_task(
|
||||
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||
at_close=True, fields=FIELDS,
|
||||
)).json()
|
||||
headers = auth_headers(maker_user)
|
||||
|
||||
# il frontend manda sempre i tre campi: fields null = nessun campo
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"in_loop": True, "at_close": False, "fields": None},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["in_loop"] is True and body["at_close"] is False and body["fields"] is None
|
||||
|
||||
# tipo diverso da note con at_close o fields: 422
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"task_type": "measure", "at_close": True, "fields": None},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"task_type": "check", "at_close": False, "fields": FIELDS},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
# un task che ha gia' at_close non puo' passare a measure senza toglierlo
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers,
|
||||
json={"at_close": True, "fields": FIELDS},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=headers, json={"task_type": "measure"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_copy_on_write_carries_the_three_fields(
|
||||
client: AsyncClient, maker_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L6")
|
||||
await db_session.commit()
|
||||
created = (await _post_task(
|
||||
client, maker_user, recipe.id, title="Nota", task_type="note",
|
||||
in_loop=True, at_close=True, fields=FIELDS,
|
||||
)).json()
|
||||
|
||||
# una misura sulla versione corrente forza il copy-on-write alla prossima modifica
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(
|
||||
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
|
||||
)
|
||||
)).scalar_one()
|
||||
subtask_id = (await db_session.execute(
|
||||
select(RecipeSubtask.id).join(RecipeSubtask.task)
|
||||
.where(RecipeSubtask.task.has(version_id=version.id))
|
||||
)).scalars().first()
|
||||
db_session.add(Measurement(
|
||||
subtask_id=subtask_id, version_id=version.id, measured_by=maker_user.id,
|
||||
value=10.0, pass_fail="pass", deviation=0.0, input_method="manual",
|
||||
))
|
||||
await db_session.commit()
|
||||
# sessione condivisa col client: la collezione version.tasks va riletta
|
||||
db_session.expire_all()
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/tasks/{created['id']}", headers=auth_headers(maker_user),
|
||||
json={"title": "Nota rinominata"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["version_id"] != version.id # e' la copia nella nuova versione
|
||||
assert body["in_loop"] is True and body["at_close"] is True
|
||||
assert body["fields"] == FIELDS
|
||||
|
||||
|
||||
async def test_run_response_loop_and_close_task_ids(
|
||||
client: AsyncClient, maker_user, measurement_tec_user, admin_user, db_session,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code="REC-L7")
|
||||
db_session.add(Station(code="ST-L7", name="S", active=True, created_by=admin_user.id))
|
||||
await db_session.commit()
|
||||
|
||||
async def mk(**body):
|
||||
resp = await _post_task(client, maker_user, recipe.id, **body)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
check = await mk(title="Check", task_type="check")
|
||||
note_loop = await mk(title="Nota ciclo", task_type="note", in_loop=True)
|
||||
note_out = await mk(title="Nota fuori", task_type="note")
|
||||
close_a = await mk(title="Chiusura A", task_type="note", at_close=True)
|
||||
measure_off = await mk(title="Misura fuori", task_type="measure", in_loop=False)
|
||||
close_b = await mk(title="Chiusura B", task_type="note", at_close=True)
|
||||
|
||||
first_id = (await db_session.execute(
|
||||
select(RecipeTask.id).where(RecipeTask.version_id == check["version_id"])
|
||||
.order_by(RecipeTask.order_index)
|
||||
)).scalars().first()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/production-runs", headers=auth_headers(measurement_tec_user),
|
||||
json={"station_code": "ST-L7", "recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
# ciclo: solo in_loop, in ordine (misura della fixture, check, nota in ciclo)
|
||||
assert body["measurement_task_ids"] == [first_id, check["id"], note_loop["id"]]
|
||||
assert note_out["id"] not in body["measurement_task_ids"]
|
||||
assert measure_off["id"] not in body["measurement_task_ids"]
|
||||
assert body["close_task_ids"] == [close_a["id"], close_b["id"]]
|
||||
|
||||
|
||||
class TestLoginPinRateLimitBucket:
|
||||
"""#59: il login con PIN sta nel bucket 'login' come il login con password."""
|
||||
|
||||
def test_login_pin_is_login_bucket(self):
|
||||
assert RateLimitMiddleware._is_login_bucket_request("POST", "/api/auth/login-pin")
|
||||
|
||||
def test_login_pin_get_is_not_login_bucket(self):
|
||||
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/login-pin")
|
||||
|
||||
def test_pin_users_list_is_not_login_bucket(self):
|
||||
assert not RateLimitMiddleware._is_login_bucket_request("GET", "/api/auth/pin-users")
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Test di POST/GET /api/task-results (#54 check OK/NOK, #55 note con campi)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.models.orm.production import ProductionRun
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
FIELDS = [
|
||||
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
|
||||
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
|
||||
]
|
||||
|
||||
|
||||
async def _setup(client, db_session, maker_user, admin_user, code):
|
||||
recipe = await create_test_recipe(db_session, user_id=maker_user.id, code=code)
|
||||
db_session.add(Station(code=f"ST-{code}", name="S", active=True, created_by=admin_user.id))
|
||||
await db_session.commit()
|
||||
h = auth_headers(maker_user)
|
||||
check = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h, json={"title": "C", "task_type": "check"},
|
||||
)).json()
|
||||
note = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=h,
|
||||
json={"title": "N", "task_type": "note", "fields": FIELDS},
|
||||
)).json()
|
||||
return recipe, check, note
|
||||
|
||||
|
||||
async def _open_run(client, user, recipe, code):
|
||||
resp = await client.post(
|
||||
"/api/production-runs", headers=auth_headers(user),
|
||||
json={"station_code": f"ST-{code}", "recipe_id": recipe.id},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _post(client, user, **body):
|
||||
return await client.post("/api/task-results", headers=auth_headers(user), json=body)
|
||||
|
||||
|
||||
async def test_requires_auth_and_role(client: AsyncClient, maker_user, admin_user, db_session):
|
||||
_, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR0")
|
||||
resp = await client.post("/api/task-results", json={"task_id": check["id"], "outcome": "ok"})
|
||||
assert resp.status_code == 401
|
||||
# un Maker senza ruolo di reparto non registra esiti
|
||||
resp = await _post(client, maker_user, task_id=check["id"], outcome="ok")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_check_ok_and_nok_rules(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR1")
|
||||
run = await _open_run(client, measurement_tec_user, recipe, "TR1")
|
||||
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=run["id"], outcome="ok")
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["outcome"] == "ok"
|
||||
assert body["version_id"] == check["version_id"]
|
||||
assert body["user_id"] == measurement_tec_user.id
|
||||
assert body["production_run_id"] == run["id"]
|
||||
assert body["created_at"]
|
||||
|
||||
for note in (None, "", " "):
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
outcome="nok", note=note)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"] == "Per un NOK serve la nota"
|
||||
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
outcome="nok", note=" graffio ")
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["note"] == "graffio"
|
||||
|
||||
# un check non si chiude con 'done'
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"], outcome="done")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_note_values_and_attachments(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, _, note = await _setup(client, db_session, maker_user, admin_user, "TR2")
|
||||
ok = await _post(
|
||||
client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
values={"peso": "12,5"},
|
||||
attachments=[{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}],
|
||||
)
|
||||
assert ok.status_code == 201, ok.text
|
||||
assert ok.json()["values"] == {"peso": "12,5"}
|
||||
assert ok.json()["attachments"] == [{"key": "foto", "path": "task_results/2026/10/abc.jpg", "name": "foto.jpg"}]
|
||||
|
||||
# solo done
|
||||
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="ok")).status_code == 422
|
||||
# chiave fuori dai campi
|
||||
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
values={"altro": "1"})).status_code == 422
|
||||
# numero non convertibile
|
||||
assert (await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
values={"peso": "abc"})).status_code == 422
|
||||
# allegati con percorso non sicuro
|
||||
for bad in ("../x.jpg", "/etc/passwd", "a/../b.jpg", "C:/x.jpg", "a\\b.jpg"):
|
||||
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
attachments=[{"key": None, "path": bad, "name": "x"}])
|
||||
assert resp.status_code == 422, bad
|
||||
# chiave allegato fuori dai campi
|
||||
resp = await _post(client, measurement_tec_user, task_id=note["id"], outcome="done",
|
||||
attachments=[{"key": "boh", "path": "task_results/a.jpg", "name": "x"}])
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_other_task_types_rejected_and_unknown_task(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, _, _ = await _setup(client, db_session, maker_user, admin_user, "TR3")
|
||||
measure = (await client.post(
|
||||
f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user),
|
||||
json={"title": "M", "task_type": "measure"},
|
||||
)).json()
|
||||
resp = await _post(client, measurement_tec_user, task_id=measure["id"], outcome="ok")
|
||||
assert resp.status_code == 422
|
||||
resp = await _post(client, measurement_tec_user, task_id=999999, outcome="ok")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_run_must_be_open_and_same_version(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4")
|
||||
_, other_check, _ = await _setup(client, db_session, maker_user, admin_user, "TR4b")
|
||||
run = await _open_run(client, measurement_tec_user, recipe, "TR4")
|
||||
|
||||
# task di un'altra versione
|
||||
resp = await _post(client, measurement_tec_user, task_id=other_check["id"],
|
||||
production_run_id=run["id"], outcome="ok")
|
||||
assert resp.status_code == 422
|
||||
# run inesistente
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=999999, outcome="ok")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# run chiuso: non registra piu'
|
||||
row = (await db_session.execute(
|
||||
select(ProductionRun).where(ProductionRun.id == run["id"])
|
||||
)).scalar_one()
|
||||
row.status = "closed"
|
||||
await db_session.commit()
|
||||
resp = await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=run["id"], outcome="ok")
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_list_filters_and_order(
|
||||
client: AsyncClient, maker_user, admin_user, measurement_tec_user, db_session,
|
||||
):
|
||||
recipe, check, note = await _setup(client, db_session, maker_user, admin_user, "TR5")
|
||||
run = await _open_run(client, measurement_tec_user, recipe, "TR5")
|
||||
a = (await _post(client, measurement_tec_user, task_id=check["id"],
|
||||
production_run_id=run["id"], outcome="ok")).json()
|
||||
b = (await _post(client, measurement_tec_user, task_id=note["id"],
|
||||
production_run_id=run["id"], outcome="done")).json()
|
||||
c = (await _post(client, measurement_tec_user, task_id=check["id"], outcome="ok")).json()
|
||||
h = auth_headers(measurement_tec_user)
|
||||
|
||||
resp = await client.get(f"/api/task-results?production_run_id={run['id']}", headers=h)
|
||||
assert [r["id"] for r in resp.json()] == [b["id"], a["id"]] # piu' recente prima
|
||||
resp = await client.get(f"/api/task-results?task_id={check['id']}", headers=h)
|
||||
assert [r["id"] for r in resp.json()] == [c["id"], a["id"]]
|
||||
resp = await client.get(
|
||||
f"/api/task-results?task_id={check['id']}&production_run_id={run['id']}", headers=h)
|
||||
assert [r["id"] for r in resp.json()] == [a["id"]]
|
||||
assert (await client.get("/api/task-results")).status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/task-results/attachments: l'operatore carica gli allegati (#55)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# firma PNG valida (magic bytes) piu' un po' di zeri
|
||||
PNG = bytes([0x89]) + b"PNG" + bytes([13, 10, 26, 10]) + bytes(32)
|
||||
|
||||
|
||||
async def test_attachment_upload_ok_for_measurement_tec(
|
||||
client: AsyncClient, measurement_tec_user, tmp_path,
|
||||
):
|
||||
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 50
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=auth_headers(measurement_tec_user),
|
||||
files={"file": ("foto bella (1).png", PNG, "image/png")},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["name"] == "fotobella1.png"
|
||||
assert body["file_path"].startswith("task_results/")
|
||||
assert body["file_path"].endswith(".png")
|
||||
assert (tmp_path / body["file_path"]).read_bytes() == PNG
|
||||
|
||||
|
||||
async def test_attachment_upload_rejects_wrong_type_size_and_role(
|
||||
client: AsyncClient, measurement_tec_user, maker_user, tmp_path,
|
||||
):
|
||||
h = auth_headers(measurement_tec_user)
|
||||
with patch("src.backend.api.routers.task_results.settings") as mock_settings:
|
||||
mock_settings.upload_path = tmp_path
|
||||
mock_settings.max_upload_size_mb = 1
|
||||
# tipo non ammesso (HTML dichiarato come png): come upload_file, 400
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=h,
|
||||
files={"file": ("x.png", b"<html></html>", "image/png")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# oltre il limite
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=h,
|
||||
files={"file": ("x.png", PNG + bytes(1024 * 1024 + 1), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# un Maker senza ruolo di reparto non carica
|
||||
resp = await client.post(
|
||||
"/api/task-results/attachments", headers=auth_headers(maker_user),
|
||||
files={"file": ("x.png", PNG, "image/png")},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert not any(tmp_path.rglob("*.png"))
|
||||
|
||||
|
||||
async def test_task_results_dir_is_undeletable(client: AsyncClient, admin_user):
|
||||
resp = await client.delete(
|
||||
"/api/files/task_results/2026/10/abc.png", headers=auth_headers(admin_user),
|
||||
)
|
||||
assert resp.status_code in (403, 404)
|
||||
assert resp.status_code == 403
|
||||
@@ -66,7 +66,7 @@ async def two_vision_quotes(db_session, recipe_id: int):
|
||||
def worker_says(monkeypatch):
|
||||
"""The worker does not run in tests: its response is faked here."""
|
||||
def _install(outputs: dict, failures: list | None = None):
|
||||
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
return {
|
||||
"outputs": outputs,
|
||||
"failures": failures or [],
|
||||
@@ -316,7 +316,7 @@ async def test_a_pending_fail_blocks_before_the_worker_is_called(
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def worker_must_not_be_called(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
raise AssertionError("the worker must not run while the operator is blocked")
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service._call_worker", worker_must_not_be_called,
|
||||
|
||||
@@ -53,7 +53,7 @@ async def a_camera_task(db_session, recipe_id: int):
|
||||
@pytest.fixture
|
||||
def worker_says(monkeypatch):
|
||||
def _install(outputs: dict):
|
||||
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
return {"outputs": outputs, "failures": [],
|
||||
"engine_version": "e" * 40, "duration_ms": 3}
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Ricetta VisionSuite v2 su un task, execute dxf_compare, copy-on-write (#46)."""
|
||||
import io
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.recipe import RecipeVersion
|
||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
|
||||
RECIPE = {
|
||||
"schema_version": 2, "name": "dxf", "tools": [],
|
||||
"meta": {"dxf": {"d1": {"path": "dxf/part.dxf"}}, "models": {"m": {"path": "models/m.npz"}}},
|
||||
}
|
||||
OUTPUTS = [{"key": "cmp.points_in_pct", "quantity": "ratio", "unit": "%"}]
|
||||
GOOD_FILES = (("part.dxf", b"0 EOF"), ("m.npz", b"npz"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def redirect_uploads(monkeypatch, tmp_path):
|
||||
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validate_says(monkeypatch):
|
||||
"""Il worker /validate non gira nei test: risposta finta."""
|
||||
def _install(ok=True, errors=None, outputs=None):
|
||||
async def fake(graph, recipe_dir):
|
||||
return {
|
||||
"ok": ok, "errors": errors or [],
|
||||
"outputs": outputs if outputs is not None else OUTPUTS,
|
||||
}
|
||||
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
|
||||
return _install
|
||||
|
||||
|
||||
def an_image() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("L", (32, 32)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
async def make_task(db_session, user, task_type="dxf_compare"):
|
||||
recipe = await create_test_recipe(db_session, user.id)
|
||||
version = (await db_session.execute(
|
||||
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||
)).scalars().first()
|
||||
task = (await db_session.execute(
|
||||
select(RecipeTask).where(RecipeTask.version_id == version.id)
|
||||
)).scalars().first()
|
||||
subtask = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().first()
|
||||
task.task_type = task_type
|
||||
await db_session.commit()
|
||||
return task, subtask
|
||||
|
||||
|
||||
def upload(client, user, task, recipe=RECIPE, files=()):
|
||||
return client.post(
|
||||
f"/api/vision/tasks/{task.id}/recipe",
|
||||
headers=auth_headers(user),
|
||||
files=[("recipe", ("r.json", json.dumps(recipe).encode(), "application/json"))]
|
||||
+ [("files", (n, c, "application/octet-stream")) for n, c in files],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_ok_stores_folder_and_task(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await upload(client, maker_user, task, files=GOOD_FILES)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["outputs"] == OUTPUTS
|
||||
assert sorted(body["files"]) == ["dxf/part.dxf", "models/m.npz"]
|
||||
assert body["recipe_dir"].startswith(f"vision/recipes/{task.id}/")
|
||||
folder = settings.upload_path / body["recipe_dir"]
|
||||
assert (folder / "recipe.json").is_file() and (folder / "dxf" / "part.dxf").is_file()
|
||||
await db_session.refresh(task)
|
||||
assert task.vision_json == RECIPE and task.vision_recipe_dir == body["recipe_dir"]
|
||||
|
||||
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
|
||||
assert got.status_code == 200
|
||||
assert got.json()["recipe"] == RECIPE and got.json()["outputs"] == OUTPUTS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_without_recipe_is_empty(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
got = await client.get(f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user))
|
||||
assert got.json() == {
|
||||
"task_id": task.id, "recipe_dir": None, "recipe": None, "files": [], "outputs": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unmatched_file_name_is_422_list(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await upload(client, maker_user, task, files=[("other.dxf", b"x")])
|
||||
assert resp.status_code == 422
|
||||
assert isinstance(resp.json()["detail"], list) and "other.dxf" in resp.json()["detail"][0]
|
||||
await db_session.refresh(task)
|
||||
assert task.vision_json is None and task.vision_recipe_dir is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traversal_in_meta_path_is_422(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
evil = {"schema_version": 2, "tools": [], "meta": {"dxf": {"d": {"path": "../../evil.dxf"}}}}
|
||||
resp = await upload(client, maker_user, task, recipe=evil, files=[("evil.dxf", b"x")])
|
||||
assert resp.status_code == 422
|
||||
assert not (settings.upload_path.parent / "evil.dxf").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wrong_task_type_is_422(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user, task_type="measure")
|
||||
resp = await upload(client, maker_user, task, files=GOOD_FILES)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_is_422(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await client.post(
|
||||
f"/api/vision/tasks/{task.id}/recipe", headers=auth_headers(maker_user),
|
||||
files=[("recipe", ("r.json", b"{nope", "application/json"))],
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_not_ok_is_422_and_task_unchanged(client, db_session, maker_user, validate_says):
|
||||
validate_says(ok=False, errors=["meta file missing"])
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
task.vision_json = {"old": 1}
|
||||
task.vision_recipe_dir = "vision/recipes/old"
|
||||
await db_session.commit()
|
||||
resp = await upload(client, maker_user, task, files=GOOD_FILES)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"] == ["meta file missing"]
|
||||
await db_session.refresh(task)
|
||||
assert task.vision_json == {"old": 1} and task.vision_recipe_dir == "vision/recipes/old"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_folder_is_not_rewritten(client, db_session, maker_user, validate_says):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
first = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
|
||||
marker = settings.upload_path / first["recipe_dir"] / "dxf" / "part.dxf"
|
||||
marker.write_bytes(b"CHANGED")
|
||||
second = (await upload(client, maker_user, task, files=GOOD_FILES)).json()
|
||||
assert second["recipe_dir"] == first["recipe_dir"]
|
||||
assert marker.read_bytes() == b"CHANGED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_requires_maker(
|
||||
client, db_session, maker_user, measurement_tec_user, validate_says,
|
||||
):
|
||||
validate_says()
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await upload(client, measurement_tec_user, task, files=GOOD_FILES)
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_dxf_compare_saves_files_and_extended_response(
|
||||
client, db_session, measurement_tec_user, monkeypatch,
|
||||
):
|
||||
task, subtask = await make_task(db_session, measurement_tec_user)
|
||||
task.vision_json = RECIPE
|
||||
task.vision_recipe_dir = "vision/recipes/1/abc"
|
||||
subtask.vision_output = "cmp.points_in_pct"
|
||||
await db_session.commit()
|
||||
seen = {}
|
||||
overlay = [{"type": "contour", "role": "found", "rows": [1], "cols": [2], "tool_id": "cmp"}]
|
||||
|
||||
async def fake_call(image_bytes, graph, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return {
|
||||
"outputs": {"cmp.points_in_pct": float(subtask.nominal)},
|
||||
"failures": [], "engine_version": "e" * 40, "duration_ms": 5,
|
||||
"overlay": overlay, "outcome": "OK",
|
||||
}
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/vision/execute", headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert seen["recipe_dir"] == "vision/recipes/1/abc" and "calibration" not in seen
|
||||
assert body["outcome"] == "OK" and body["overlay"] == overlay and body["failures"] == []
|
||||
assert body["image_url"] == f"/api/vision/results/{body['vision_result_id']}/image"
|
||||
assert body["measurements"][0]["pass_fail"] == "pass"
|
||||
|
||||
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
assert (settings.upload_path / result.image_path).read_bytes() == an_image()
|
||||
assert json.loads((settings.upload_path / result.overlay_path).read_text()) == overlay
|
||||
|
||||
img = await client.get(body["image_url"], headers=auth_headers(measurement_tec_user))
|
||||
assert img.status_code == 200 and img.content == an_image()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_dxf_compare_out_of_tolerance_fails(
|
||||
client, db_session, measurement_tec_user, monkeypatch,
|
||||
):
|
||||
task, subtask = await make_task(db_session, measurement_tec_user)
|
||||
task.vision_json = RECIPE
|
||||
subtask.vision_output = "cmp.points_in_pct"
|
||||
await db_session.commit()
|
||||
|
||||
async def fake_call(image_bytes, graph, **kwargs):
|
||||
return {"outputs": {"cmp.points_in_pct": 99.0}, "failures": [],
|
||||
"engine_version": "e" * 40, "outcome": "OK"}
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
resp = await client.post(
|
||||
"/api/vision/execute", headers=auth_headers(measurement_tec_user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# il verdetto e' delle tolleranze del subtask, non di outcome di VisionSuite
|
||||
assert resp.json()["measurements"][0]["pass_fail"] == "fail"
|
||||
assert resp.json()["overlay"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_on_write_carries_recipe_dir(client, db_session, maker_user):
|
||||
from src.backend.tests.test_vision_copy_on_write import (
|
||||
_current_task_and_subtask, _seed_measurement,
|
||||
)
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||
task.task_type = "dxf_compare"
|
||||
task.vision_json = RECIPE
|
||||
task.vision_recipe_dir = "vision/recipes/1/abc"
|
||||
await db_session.flush()
|
||||
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||
db_session.expunge_all()
|
||||
resp = await client.put(
|
||||
f"/api/recipes/{recipe.id}", headers=auth_headers(maker_user),
|
||||
json={"change_notes": "cow"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
db_session.expunge_all()
|
||||
version, new_task, _ = await _current_task_and_subtask(db_session, recipe.id)
|
||||
assert version.version_number == 2
|
||||
assert new_task.vision_recipe_dir == "vision/recipes/1/abc" and new_task.vision_json == RECIPE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_with_missing_file_then_complete_succeeds(
|
||||
client, db_session, maker_user, monkeypatch,
|
||||
):
|
||||
# worker finto: ok solo se il DXF esiste nella cartella della ricetta
|
||||
async def fake(graph, recipe_dir):
|
||||
present = (settings.upload_path / recipe_dir / "dxf" / "part.dxf").is_file()
|
||||
return {"ok": present, "errors": [] if present else ["dxf missing"], "outputs": OUTPUTS}
|
||||
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
|
||||
first = await upload(client, maker_user, task, files=[])
|
||||
assert first.status_code == 422
|
||||
second = await upload(client, maker_user, task, files=GOOD_FILES)
|
||||
assert second.status_code == 200, second.text
|
||||
@@ -39,7 +39,7 @@ async def recipe_parts(db_session, recipe_id: int):
|
||||
@pytest.fixture
|
||||
def worker_says(monkeypatch):
|
||||
def _install(outputs: dict):
|
||||
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
return {"outputs": outputs, "failures": [],
|
||||
"engine_version": "b" * 40, "duration_ms": 7}
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""#45: il worker di visione si sceglie per stazione.
|
||||
|
||||
La stazione si legge dalla sessione di produzione (`production_run_id`): con un
|
||||
URL sulla stazione `_call_worker` lo riceve e il VisionResult lo registra;
|
||||
senza URL o senza run si resta sul worker centrale, senza ripiego fra i due.
|
||||
"""
|
||||
import io
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from PIL import Image
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.api.production import ProductionRunCreate
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.services import production_service
|
||||
from src.backend.services.vision_service import _call_worker
|
||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||
from src.backend.tests.test_vision_execute import recipe_parts
|
||||
from sqlalchemy import select
|
||||
|
||||
STATION_URL = "http://10.0.0.7:8100"
|
||||
|
||||
|
||||
def an_image() -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_calls(monkeypatch):
|
||||
"""Fake `_call_worker` che ricorda i kwargs con cui e' stato chiamato."""
|
||||
calls: list[dict] = []
|
||||
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
calls.append(kwargs)
|
||||
return {"outputs": {"t1.diameter": 10.0}, "failures": [],
|
||||
"engine_version": "e" * 40, "duration_ms": 3}
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
|
||||
return calls
|
||||
|
||||
|
||||
async def _camera_task(db_session, user_id: int):
|
||||
recipe = await create_test_recipe(db_session, user_id)
|
||||
version, task, subtask = await recipe_parts(db_session, recipe.id)
|
||||
task.task_type = "camera_measure"
|
||||
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
|
||||
subtask.vision_output = "t1.diameter"
|
||||
await db_session.commit()
|
||||
return recipe, task
|
||||
|
||||
|
||||
async def _station_with_run(db_session, user, recipe, worker_url):
|
||||
station = Station(
|
||||
code="ST-W", name="Stazione W", active=True, created_by=user.id,
|
||||
vision_worker_url=worker_url,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(station)
|
||||
run = await production_service.open_run(
|
||||
db_session,
|
||||
ProductionRunCreate(station_code=station.code, recipe_id=recipe.id),
|
||||
user,
|
||||
)
|
||||
await db_session.commit()
|
||||
return station, run
|
||||
|
||||
|
||||
async def _execute(client, user, task, run=None):
|
||||
data = {"task_id": str(task.id)}
|
||||
if run is not None:
|
||||
data["production_run_id"] = str(run.id)
|
||||
response = await client.post(
|
||||
"/api/vision/execute",
|
||||
headers=auth_headers(user),
|
||||
files={"image": ("p.png", an_image(), "image/png")},
|
||||
data=data,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
async def _the_result(db_session) -> VisionResult:
|
||||
return (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_station_with_url_gets_the_call_and_the_record(
|
||||
client, db_session, measurement_tec_user, worker_calls,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(
|
||||
db_session, measurement_tec_user, recipe, STATION_URL)
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert worker_calls[0]["worker_url"] == STATION_URL
|
||||
result = await _the_result(db_session)
|
||||
assert result.executed_on == "station"
|
||||
assert result.worker_url == STATION_URL
|
||||
assert result.station_id == station.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_station_without_url_uses_the_central_worker(
|
||||
client, db_session, measurement_tec_user, worker_calls,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(
|
||||
db_session, measurement_tec_user, recipe, None)
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert worker_calls[0]["worker_url"] is None
|
||||
result = await _the_result(db_session)
|
||||
assert result.executed_on == "server"
|
||||
assert result.worker_url == settings.vision_worker_url.rstrip("/")
|
||||
assert result.station_id == station.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_without_a_run_the_central_worker_is_used(
|
||||
client, db_session, measurement_tec_user, worker_calls,
|
||||
):
|
||||
_, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
|
||||
await _execute(client, measurement_tec_user, task)
|
||||
|
||||
assert worker_calls[0]["worker_url"] is None
|
||||
result = await _the_result(db_session)
|
||||
assert result.executed_on == "server"
|
||||
assert result.worker_url == settings.vision_worker_url.rstrip("/")
|
||||
assert result.station_id is None
|
||||
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
|
||||
def json(self):
|
||||
return {"outputs": {}, "failures": [], "engine_version": "e" * 40}
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
"""httpx.AsyncClient finto: registra gli URL postati, opzionalmente fallisce."""
|
||||
|
||||
def __init__(self, posts: list, raises=None):
|
||||
self._posts = posts
|
||||
self._raises = raises
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def post(self, url, *args, **kwargs):
|
||||
self._posts.append(url)
|
||||
if self._raises is not None:
|
||||
raise self._raises
|
||||
return _Response()
|
||||
|
||||
|
||||
def _install(monkeypatch, raises=None) -> list:
|
||||
posts: list = []
|
||||
monkeypatch.setattr(
|
||||
"src.backend.services.vision_service.httpx.AsyncClient",
|
||||
lambda *a, **k: _RecordingClient(posts, raises),
|
||||
)
|
||||
return posts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_worker_posts_to_the_given_url(monkeypatch):
|
||||
posts = _install(monkeypatch)
|
||||
# lo "/" finale non deve produrre "//run"
|
||||
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL + "/")
|
||||
assert posts == [f"{STATION_URL}/run"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_worker_defaults_to_the_central_url(monkeypatch):
|
||||
posts = _install(monkeypatch)
|
||||
await _call_worker(b"img", {"tools": []})
|
||||
assert posts == [f"{settings.vision_worker_url.rstrip('/')}/run"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_station_worker_down_is_502_with_its_url_and_no_fallback(monkeypatch):
|
||||
posts = _install(monkeypatch, raises=httpx.ConnectError("refused"))
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL)
|
||||
assert exc.value.status_code == 502
|
||||
assert STATION_URL in exc.value.detail
|
||||
# una sola POST: il centrale non viene tentato
|
||||
assert posts == [f"{STATION_URL}/run"]
|
||||
@@ -69,7 +69,7 @@ async def recipe_parts(db_session, recipe_id: int):
|
||||
@pytest.fixture
|
||||
def worker_says(monkeypatch):
|
||||
def _install(outputs: dict):
|
||||
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
|
||||
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
|
||||
return {"outputs": outputs, "failures": [],
|
||||
"engine_version": "c" * 40, "duration_ms": 5}
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -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():
|
||||
@@ -240,6 +242,9 @@ def create_app() -> Flask:
|
||||
"company_logo": session.get("company_logo"),
|
||||
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||
"current_year": date.today().year,
|
||||
# Versione/commit nel piede di pagina (#70)
|
||||
"app_version": Config.APP_VERSION,
|
||||
"app_commit": Config.APP_COMMIT,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
@@ -29,28 +29,30 @@ def admin_required(f):
|
||||
@login_required
|
||||
@admin_required
|
||||
def user_list():
|
||||
"""User management page."""
|
||||
resp = api_client.get("/api/users")
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
flash(_("Errore nel caricamento degli utenti: %(error)s", error=resp.get("detail", "")), "error")
|
||||
users = []
|
||||
elif isinstance(resp, list):
|
||||
users = resp
|
||||
else:
|
||||
users = []
|
||||
|
||||
return render_template("admin/users.html", users=users)
|
||||
"""#50: la gestione utenti vive nella tab «Utenti» di Impostazioni."""
|
||||
return redirect(url_for("admin.settings_page", tab="utenti"))
|
||||
|
||||
|
||||
@admin_bp.route("/settings")
|
||||
@login_required
|
||||
@admin_required
|
||||
def settings_page():
|
||||
"""System settings page."""
|
||||
"""System settings page (admin only), con la tab «Utenti» (#50)."""
|
||||
resp = api_client.get("/api/settings")
|
||||
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
|
||||
return render_template("admin/settings.html", settings=settings)
|
||||
# Solo un dict valido sono impostazioni: un errore o altro tipo = nessuna
|
||||
settings = resp if isinstance(resp, dict) and not resp.get("error") else {}
|
||||
|
||||
users_resp = api_client.get("/api/users")
|
||||
if isinstance(users_resp, dict) and users_resp.get("error"):
|
||||
flash(_("Errore nel caricamento degli utenti: %(error)s", error=users_resp.get("detail", "")), "error")
|
||||
users = []
|
||||
elif isinstance(users_resp, list):
|
||||
users = users_resp
|
||||
else:
|
||||
users = []
|
||||
|
||||
tab = "utenti" if request.args.get("tab") == "utenti" else "generale"
|
||||
return render_template("admin/settings.html", settings=settings, users=users, active_tab=tab)
|
||||
|
||||
|
||||
@admin_bp.route("/api/settings", methods=["PUT"])
|
||||
@@ -136,6 +138,21 @@ def api_change_password(user_id: int):
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@admin_bp.route("/api/users/<int:user_id>/pin", methods=["PUT"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_set_pin(user_id: int):
|
||||
"""Proxy: set (4 digits) or remove ({"pin": null}) a user's PIN (#59)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
# Forward only the pin field; backend answers 422 for admins / malformed PINs.
|
||||
resp = api_client.put(f"/api/users/{user_id}/pin", data={"pin": data.get("pin")})
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@admin_bp.route("/api/users/<int:user_id>/toggle-active", methods=["POST"])
|
||||
@login_required
|
||||
@admin_required
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -5,7 +5,7 @@ import requests as http_requests
|
||||
|
||||
from flask import (
|
||||
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for,
|
||||
g, request, session, url_for,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
|
||||
@@ -654,6 +654,74 @@ def api_pending_authorisation():
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
def _supervisor_credentials(data: dict):
|
||||
"""The capoturno's credentials from the request body (#59).
|
||||
|
||||
Two ways in: ``supervisor_user_id`` + ``supervisor_pin`` (name and 4-digit PIN
|
||||
from the touch pad) or, as before, ``supervisor_username`` +
|
||||
``supervisor_password``. Returns ``(fields, None)`` with the pair to forward,
|
||||
or ``(None, error_response)`` when neither pair is complete.
|
||||
"""
|
||||
pin = str(data.get("supervisor_pin") or "").strip()
|
||||
user_id = data.get("supervisor_user_id")
|
||||
if pin and user_id not in (None, ""):
|
||||
try:
|
||||
return {"supervisor_user_id": int(user_id), "supervisor_pin": pin}, None
|
||||
except (TypeError, ValueError):
|
||||
pass # user_id not a number: falls through to the missing-credentials answer
|
||||
username = (data.get("supervisor_username") or "").strip()
|
||||
password = data.get("supervisor_password") or ""
|
||||
if username and password:
|
||||
return {"supervisor_username": username, "supervisor_password": password}, None
|
||||
return None, (jsonify({
|
||||
"error": True, "detail": _("Username e password richiesti"),
|
||||
}), 400)
|
||||
|
||||
|
||||
def _post_supervised(endpoint: str, creds: dict, extra: dict | None = None):
|
||||
"""POST an action carrying the capoturno's credentials, forwarding the answer.
|
||||
|
||||
api_client reads every 401 as "your API key is dead" and empties the Flask
|
||||
session. For these endpoints a 401 can also be the capoturno's wrong PIN or
|
||||
password, and that must not throw the operator out of their session (#59):
|
||||
when the detail names the supervisor or the PIN, the session is put back and
|
||||
the ``session_expired`` flag is dropped so the modal can say "PIN errato".
|
||||
"""
|
||||
saved = dict(session)
|
||||
resp = api_client.post(endpoint, data={**creds, **(extra or {})})
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
detail = str(resp.get("detail") or "").lower()
|
||||
if resp.get("status_code") == 401 and ("supervisor" in detail or "pin" in detail):
|
||||
session.update(saved) # the 401 was about the capoturno, not about this session
|
||||
g.session_expired = False
|
||||
resp = {k: v for k, v in resp.items() if k != "session_expired"}
|
||||
if "supervisor_pin" in creds:
|
||||
resp["detail"] = _("PIN errato") # the backend's English text is not for the operator
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@measure_bp.route("/api/pin-users", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_pin_users():
|
||||
"""Proxy: names for the capoturno's PIN pad (#59), id and display name only.
|
||||
|
||||
Empty list when the backend does not answer, so the modal falls back to the
|
||||
password form instead of showing a pad nobody can use.
|
||||
"""
|
||||
try:
|
||||
resp = api_client.get("/api/auth/pin-users")
|
||||
except Exception:
|
||||
return jsonify([])
|
||||
if not isinstance(resp, list):
|
||||
return jsonify([])
|
||||
return jsonify([
|
||||
{"id": u.get("id"), "display_name": u.get("display_name", "")}
|
||||
for u in resp if isinstance(u, dict)
|
||||
])
|
||||
|
||||
|
||||
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
@@ -665,20 +733,10 @@ def api_authorise_measurement(measurement_id: int):
|
||||
written down nowhere and nothing depending on it.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = (data.get("supervisor_username") or "").strip()
|
||||
password = data.get("supervisor_password") or ""
|
||||
if not username or not password:
|
||||
return jsonify({
|
||||
"error": True, "detail": _("Username e password richiesti"),
|
||||
}), 400
|
||||
|
||||
resp = api_client.post(
|
||||
f"/api/measurements/{measurement_id}/authorise",
|
||||
data={"supervisor_username": username, "supervisor_password": password},
|
||||
)
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||
if error:
|
||||
return error
|
||||
return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -770,24 +828,12 @@ def _supervised_action(run_id: int, action: str):
|
||||
leave no such record, and is an extra round trip besides.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = (data.get("supervisor_username") or "").strip()
|
||||
password = data.get("supervisor_password") or ""
|
||||
if not username or not password:
|
||||
return jsonify({
|
||||
"error": True, "detail": _("Username e password richiesti"),
|
||||
}), 400
|
||||
|
||||
resp = api_client.post(
|
||||
f"/api/production-runs/{run_id}/{action}",
|
||||
data={
|
||||
"supervisor_username": username,
|
||||
"supervisor_password": password,
|
||||
"note": data.get("note"),
|
||||
},
|
||||
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||
if error:
|
||||
return error
|
||||
return _post_supervised(
|
||||
f"/api/production-runs/{run_id}/{action}", creds, {"note": data.get("note")},
|
||||
)
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
|
||||
@@ -814,6 +860,91 @@ def api_close_production(run_id: int):
|
||||
return _supervised_action(run_id, "close")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes: task results (#53 #54 #55) - outcome of a "check" task and the values /
|
||||
# attachments of a "note" task with fields. The browser cannot send X-API-Key,
|
||||
# so each call goes through here.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _relay(resp, ok_status: int = 200):
|
||||
"""JSON back to the browser: backend errors keep their status and detail.
|
||||
|
||||
The detail is what the operator reads («Per un NOK serve la nota»), so it is
|
||||
passed through as it came, never replaced by a generic message.
|
||||
"""
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
body = {"error": True, "detail": resp.get("detail", _("Errore nel salvataggio"))}
|
||||
if resp.get("session_expired"):
|
||||
body["session_expired"] = True
|
||||
status_code = resp.get("status_code", 500)
|
||||
return jsonify(body), status_code if status_code >= 400 else 500
|
||||
return jsonify(resp), ok_status
|
||||
|
||||
|
||||
@measure_bp.route("/api/tasks/<int:task_id>", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_get_task(task_id: int):
|
||||
"""Proxy: a task with its fields (#55: the tasks to fill at the end of production)."""
|
||||
return _relay(api_client.get(f"/api/tasks/{task_id}"))
|
||||
|
||||
|
||||
@measure_bp.route("/api/task-results", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_save_task_result():
|
||||
"""Proxy: record the result of a check / note task (#54, #55)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
if data.get("task_id") is None:
|
||||
return jsonify({
|
||||
"error": True, "detail": _("Dati mancanti: task_id è obbligatorio"),
|
||||
}), 400
|
||||
payload = {
|
||||
"task_id": data.get("task_id"),
|
||||
"production_run_id": data.get("production_run_id"),
|
||||
"outcome": data.get("outcome"),
|
||||
"note": data.get("note"),
|
||||
"values": data.get("values"),
|
||||
"attachments": data.get("attachments"),
|
||||
}
|
||||
return _relay(api_client.post("/api/task-results", data=payload), 201)
|
||||
|
||||
|
||||
@measure_bp.route("/api/task-results", methods=["GET"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_list_task_results():
|
||||
"""Proxy: results already recorded, filtered by production run and/or task."""
|
||||
params = {
|
||||
key: request.args[key]
|
||||
for key in ("production_run_id", "task_id")
|
||||
if request.args.get(key)
|
||||
}
|
||||
return _relay(api_client.get("/api/task-results", params=params))
|
||||
|
||||
|
||||
@measure_bp.route("/api/attachments", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def api_upload_attachment():
|
||||
"""Proxy: upload an attachment of a note task (image or PDF), multipart.
|
||||
|
||||
Answers with the backend's reply: the `file_path` in it is what goes into the
|
||||
`attachments` of the result.
|
||||
"""
|
||||
upload = request.files.get("file")
|
||||
if upload is None or not upload.filename:
|
||||
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
|
||||
resp = api_client.post(
|
||||
"/api/task-results/attachments", # #55: upload per l operatore (files/upload e solo Maker)
|
||||
files={"file": (
|
||||
upload.filename,
|
||||
upload.read(),
|
||||
upload.mimetype or "application/octet-stream",
|
||||
)},
|
||||
)
|
||||
return _relay(resp, 201)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: File proxy (browser can't send X-API-Key directly)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Live monitor of the open production runs (#57).
|
||||
|
||||
Page at /monitor for admin and Supervisor (capoturno); the page polls a JSON proxy
|
||||
of GET /api/production-runs/open every 10 seconds.
|
||||
"""
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, abort, jsonify, render_template, session
|
||||
|
||||
from blueprints.auth import login_required
|
||||
from services.api_client import api_client
|
||||
|
||||
monitor_bp = Blueprint("monitor", __name__)
|
||||
|
||||
|
||||
def monitor_required(f):
|
||||
"""Admin or Supervisor only: the same people the backend lets read /open."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
user = session.get("user", {})
|
||||
if not (user.get("is_admin") or "Supervisor" in user.get("roles", [])):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
@monitor_bp.route("", strict_slashes=False)
|
||||
@login_required
|
||||
@monitor_required
|
||||
def index():
|
||||
return render_template("monitor/index.html")
|
||||
|
||||
|
||||
@monitor_bp.route("/api/runs")
|
||||
@login_required
|
||||
@monitor_required
|
||||
def api_runs():
|
||||
"""JSON proxy polled by the page."""
|
||||
resp = api_client.get("/api/production-runs/open")
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
body = {"error": True, "detail": resp.get("detail", "")}
|
||||
if resp.get("session_expired"):
|
||||
body["session_expired"] = True
|
||||
return jsonify(body), resp.get("status_code", 500)
|
||||
return jsonify(resp)
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import Blueprint, jsonify, render_template, request, session
|
||||
from flask import Blueprint, abort, jsonify, render_template, request, session
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
from services.file_proxy import proxy_file # #58: allegati dei task
|
||||
|
||||
statistics_bp = Blueprint("statistics", __name__)
|
||||
|
||||
@@ -50,7 +51,7 @@ def _proxy_statistics(endpoint: str):
|
||||
for key in [
|
||||
"recipe_id", "version_id", "subtask_id",
|
||||
"date_from", "date_to", "operator_id",
|
||||
"lot_number", "serial_number", "n_bins",
|
||||
"lot_number", "serial_number", "n_bins", "production_run_id", # #58
|
||||
]:
|
||||
val = request.args.get(key)
|
||||
if val is not None and val != "":
|
||||
@@ -127,6 +128,54 @@ def api_subtasks():
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/runs")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_runs():
|
||||
"""Proxy: production runs of a recipe (#58, "Sessione" filter)."""
|
||||
params = {}
|
||||
recipe_id = request.args.get("recipe_id")
|
||||
if recipe_id:
|
||||
params["recipe_id"] = recipe_id
|
||||
|
||||
resp = api_client.get("/api/statistics/runs", params=params)
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
error_body = {"error": True, "detail": resp.get("detail", "")}
|
||||
if resp.get("session_expired"):
|
||||
error_body["session_expired"] = True
|
||||
return jsonify(error_body), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/runs/<int:run_id>/task-results")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_run_task_results(run_id: int):
|
||||
"""Proxy: esiti, note, campi e allegati dei task di una sessione (#58)."""
|
||||
resp = api_client.get(f"/api/statistics/runs/{run_id}/task-results")
|
||||
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
error_body = {"error": True, "detail": resp.get("detail", "")}
|
||||
if resp.get("session_expired"):
|
||||
error_body["session_expired"] = True
|
||||
return jsonify(error_body), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp)
|
||||
|
||||
|
||||
@statistics_bp.route("/api/files/<path:file_path>")
|
||||
@login_required
|
||||
@role_required("Metrologist")
|
||||
def api_task_result_file(file_path: str):
|
||||
"""Proxy: allegati dei task (#58). Solo task_results/, mai altri file."""
|
||||
# #58: il Metrologist non deve poter leggere altro dell'upload dir (es. statistics/).
|
||||
if not file_path.startswith("task_results/") or ".." in file_path.split("/"):
|
||||
abort(404)
|
||||
return proxy_file(file_path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PROXY REPORT PDF → FastAPI
|
||||
# ============================================================================
|
||||
@@ -137,7 +186,7 @@ def _proxy_report(endpoint: str):
|
||||
for key in [
|
||||
"recipe_id", "version_id", "subtask_id",
|
||||
"date_from", "date_to", "operator_id",
|
||||
"lot_number", "serial_number",
|
||||
"lot_number", "serial_number", "production_run_id", # #58
|
||||
]:
|
||||
val = request.args.get(key)
|
||||
if val is not None and val != "":
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""TieMeasureFlow Client Configuration."""
|
||||
import os
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
@@ -10,6 +12,25 @@ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
|
||||
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
|
||||
|
||||
|
||||
def _read_app_version() -> str:
|
||||
"""Versione del prodotto = `[project] version` di pyproject.toml (#70).
|
||||
|
||||
Si risale dalla cartella del modulo: nel container il file sta in
|
||||
/app/pyproject.toml (Dockerfile.frontend), nel repo alla radice. Se non
|
||||
si trova o e' illeggibile torna "dev": il piede di pagina non deve mai
|
||||
rompere l'avvio.
|
||||
"""
|
||||
for parent in Path(__file__).resolve().parents:
|
||||
candidate = parent / "pyproject.toml"
|
||||
if candidate.is_file():
|
||||
try:
|
||||
with candidate.open("rb") as fh:
|
||||
return str(tomllib.load(fh)["project"]["version"])
|
||||
except (OSError, tomllib.TOMLDecodeError, KeyError):
|
||||
return "dev"
|
||||
return "dev"
|
||||
|
||||
|
||||
class Config:
|
||||
"""Flask client configuration."""
|
||||
|
||||
@@ -17,6 +38,12 @@ class Config:
|
||||
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
|
||||
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
|
||||
|
||||
# Versione e commit mostrati nel piede di pagina (#70). Letti una volta
|
||||
# all'import. Il commit arriva dal build arg APP_COMMIT (.git non e' nel
|
||||
# contesto di build, vedi .dockerignore); facoltativo: vuoto = non mostrato.
|
||||
APP_VERSION: str = _read_app_version()
|
||||
APP_COMMIT: str = os.environ.get("APP_COMMIT", "")[:7]
|
||||
|
||||
# API Server connection
|
||||
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* image-crop.js — ritaglio di immagine/PDF prima dell'upload (#56).
|
||||
*
|
||||
* Cosa fa: ImageCrop.open(file) apre una modale con un rettangolo di ritaglio
|
||||
* trascinabile e ridimensionabile (mouse e touch, Pointer Events). Per un PDF
|
||||
* rasterizza la pagina scelta con pdf.js (>= 2000 px sul lato lungo).
|
||||
* Risolve: Blob PNG del ritaglio (a piena risoluzione) | il file originale
|
||||
* ("Usa originale") | null ("Annulla").
|
||||
* Come si usa: ImageCrop.configure({pdfSrc, workerSrc, labels}) una volta dal
|
||||
* template (URL di vendor/pdf e stringhe tradotte), poi `await ImageCrop.open(file)`.
|
||||
* Dipendenze: nessuna obbligatoria. pdf.js (gia' vendorizzato) viene caricato
|
||||
* al bisogno solo per i PDF; il ritaglio usa un canvas 2D semplice invece di
|
||||
* fabric.js perche' serve solo un rettangolo, non un editor di oggetti.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
var cfg = {
|
||||
pdfSrc: '',
|
||||
workerSrc: '',
|
||||
labels: {
|
||||
title: 'Ritaglia immagine',
|
||||
page: 'Pagina',
|
||||
hint: 'Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.',
|
||||
pdfNote: 'Un PDF ritagliato viene salvato come immagine PNG.',
|
||||
confirm: 'Conferma ritaglio',
|
||||
original: 'Usa originale',
|
||||
cancel: 'Annulla',
|
||||
loading: 'Caricamento...',
|
||||
error: 'Impossibile aprire il file per il ritaglio.'
|
||||
}
|
||||
};
|
||||
|
||||
var MIN_SIDE = 16; // lato minimo del ritaglio, in px immagine
|
||||
var HANDLE = 12; // raggio di presa delle maniglie, in px schermo
|
||||
var PDF_MIN_LONG = 2000; // lato lungo minimo del raster PDF
|
||||
var PDF_MAX_LONG = 6000; // tetto per non esaurire la memoria del canvas
|
||||
|
||||
function configure(opts) {
|
||||
if (!opts) return;
|
||||
if (opts.pdfSrc) cfg.pdfSrc = opts.pdfSrc;
|
||||
if (opts.workerSrc) cfg.workerSrc = opts.workerSrc;
|
||||
if (opts.labels) for (var k in opts.labels) cfg.labels[k] = opts.labels[k];
|
||||
}
|
||||
|
||||
function isPdf(file) {
|
||||
return file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '');
|
||||
}
|
||||
|
||||
function loadPdfJs() {
|
||||
if (global.pdfjsLib) return Promise.resolve(global.pdfjsLib);
|
||||
if (!cfg.pdfSrc) return Promise.reject(new Error('pdf.js non configurato'));
|
||||
return new Promise(function (resolve, reject) {
|
||||
var s = document.createElement('script');
|
||||
s.src = cfg.pdfSrc;
|
||||
s.onload = function () { resolve(global.pdfjsLib); };
|
||||
s.onerror = function () { reject(new Error('pdf.js non caricato')); };
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
function readImage(file) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var url = URL.createObjectURL(file);
|
||||
var img = new Image();
|
||||
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
|
||||
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('immagine illeggibile')); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function renderPdfPage(pdf, n) {
|
||||
return pdf.getPage(n).then(function (page) {
|
||||
var base = page.getViewport({ scale: 1 });
|
||||
var long = Math.max(base.width, base.height);
|
||||
var scale = Math.min(PDF_MAX_LONG / long, Math.max(2, PDF_MIN_LONG / long));
|
||||
var vp = page.getViewport({ scale: scale });
|
||||
var c = document.createElement('canvas');
|
||||
c.width = Math.round(vp.width);
|
||||
c.height = Math.round(vp.height);
|
||||
return page.render({ canvasContext: c.getContext('2d'), viewport: vp }).promise
|
||||
.then(function () { return c; });
|
||||
});
|
||||
}
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
function open(file) {
|
||||
var L = cfg.labels;
|
||||
return new Promise(function (resolve) {
|
||||
var src = null; // Image o canvas sorgente a piena risoluzione
|
||||
var rect = null; // {x,y,w,h} in px immagine
|
||||
var pdfDoc = null;
|
||||
var closed = false;
|
||||
|
||||
// ---- DOM (stile Maker: variabili CSS del tema + classi btn gia' presenti) ----
|
||||
var overlay = el('div', 'fixed inset-0 z-[100] flex items-center justify-center p-3 bg-black/60');
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
var box = el('div', 'rounded-xl shadow-xl flex flex-col w-full max-w-4xl max-h-full');
|
||||
box.style.cssText = 'background:var(--bg-primary,#fff);color:var(--text-primary,#111);border:1px solid var(--border-color,#ddd);';
|
||||
var head = el('div', 'flex items-center justify-between gap-3 px-4 py-3');
|
||||
head.appendChild(el('h3', 'font-semibold text-sm', L.title));
|
||||
var pageWrap = el('label', 'text-xs items-center gap-2');
|
||||
pageWrap.style.display = 'none';
|
||||
pageWrap.appendChild(document.createTextNode(L.page + ' '));
|
||||
var pageSel = el('select', 'tmf-input text-xs');
|
||||
pageSel.style.width = 'auto';
|
||||
pageWrap.appendChild(pageSel);
|
||||
head.appendChild(pageWrap);
|
||||
var body = el('div', 'px-4 flex-1 min-h-0 overflow-auto flex justify-center');
|
||||
var status = el('p', 'text-xs py-6', L.loading);
|
||||
body.appendChild(status);
|
||||
var canvas = el('canvas');
|
||||
canvas.style.cssText = 'max-width:100%;max-height:65vh;touch-action:none;cursor:crosshair;display:none;';
|
||||
body.appendChild(canvas);
|
||||
var foot = el('div', 'px-4 py-3 flex flex-wrap items-center justify-between gap-2');
|
||||
var note = el('p', 'text-xs');
|
||||
note.style.color = 'var(--text-muted,#666)';
|
||||
note.textContent = L.hint + (isPdf(file) ? ' ' + L.pdfNote : '');
|
||||
var btns = el('div', 'flex gap-2');
|
||||
var bCancel = el('button', 'btn btn-secondary text-xs', L.cancel);
|
||||
var bOrig = el('button', 'btn btn-secondary text-xs', L.original);
|
||||
var bOk = el('button', 'btn btn-primary text-xs', L.confirm);
|
||||
[bCancel, bOrig, bOk].forEach(function (b) { b.type = 'button'; btns.appendChild(b); });
|
||||
bOk.disabled = true;
|
||||
foot.appendChild(note);
|
||||
foot.appendChild(btns);
|
||||
box.appendChild(head); box.appendChild(body); box.appendChild(foot);
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
function onKey(e) { if (e.key === 'Escape') finish(null); }
|
||||
function finish(value) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
document.removeEventListener('keydown', onKey);
|
||||
overlay.remove();
|
||||
resolve(value);
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
bCancel.onclick = function () { finish(null); };
|
||||
bOrig.onclick = function () { finish(file); };
|
||||
bOk.onclick = function () {
|
||||
if (!src || !rect) return;
|
||||
var out = document.createElement('canvas');
|
||||
out.width = Math.round(rect.w);
|
||||
out.height = Math.round(rect.h);
|
||||
out.getContext('2d').drawImage(src, Math.round(rect.x), Math.round(rect.y),
|
||||
out.width, out.height, 0, 0, out.width, out.height);
|
||||
out.toBlob(function (blob) { finish(blob || file); }, 'image/png');
|
||||
};
|
||||
|
||||
// ---- disegno ----
|
||||
function sw() { return src.width || src.naturalWidth; }
|
||||
function sh() { return src.height || src.naturalHeight; }
|
||||
function handlePoints(r) {
|
||||
var mx = r.x + r.w / 2, my = r.y + r.h / 2, ex = r.x + r.w, ey = r.y + r.h;
|
||||
return [
|
||||
{ n: 'nw', x: r.x, y: r.y }, { n: 'n', x: mx, y: r.y }, { n: 'ne', x: ex, y: r.y },
|
||||
{ n: 'e', x: ex, y: my }, { n: 'se', x: ex, y: ey }, { n: 's', x: mx, y: ey },
|
||||
{ n: 'sw', x: r.x, y: ey }, { n: 'w', x: r.x, y: my }
|
||||
];
|
||||
}
|
||||
function draw() {
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(src, 0, 0, canvas.width, canvas.height);
|
||||
var r = rect; // il canvas di visualizzazione ha la risoluzione della sorgente
|
||||
// velo scuro fuori dal ritaglio (4 fasce)
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.fillRect(0, 0, canvas.width, r.y);
|
||||
ctx.fillRect(0, r.y + r.h, canvas.width, canvas.height - r.y - r.h);
|
||||
ctx.fillRect(0, r.y, r.x, r.h);
|
||||
ctx.fillRect(r.x + r.w, r.y, canvas.width - r.x - r.w, r.h);
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.lineWidth = Math.max(2, canvas.width / 400);
|
||||
ctx.strokeRect(r.x, r.y, r.w, r.h);
|
||||
ctx.fillStyle = '#3b82f6';
|
||||
var hs = Math.max(8, canvas.width / 80);
|
||||
handlePoints(r).forEach(function (p) { ctx.fillRect(p.x - hs / 2, p.y - hs / 2, hs, hs); });
|
||||
}
|
||||
function setSource(s) {
|
||||
src = s;
|
||||
canvas.width = sw();
|
||||
canvas.height = sh();
|
||||
rect = { x: 0, y: 0, w: sw(), h: sh() }; // all'inizio: tutta l'immagine
|
||||
status.style.display = 'none';
|
||||
canvas.style.display = 'block';
|
||||
bOk.disabled = false;
|
||||
draw();
|
||||
}
|
||||
|
||||
// ---- interazione ----
|
||||
var drag = null;
|
||||
function toImg(e) {
|
||||
var b = canvas.getBoundingClientRect();
|
||||
return { x: (e.clientX - b.left) * sw() / b.width, y: (e.clientY - b.top) * sh() / b.height,
|
||||
k: sw() / b.width }; // k = px immagine per px schermo
|
||||
}
|
||||
function hit(p) {
|
||||
var best = null, bd = HANDLE * p.k;
|
||||
handlePoints(rect).forEach(function (h) {
|
||||
var d = Math.max(Math.abs(h.x - p.x), Math.abs(h.y - p.y));
|
||||
if (d <= bd) { bd = d; best = h.n; }
|
||||
});
|
||||
if (best) return best;
|
||||
if (p.x > rect.x && p.x < rect.x + rect.w && p.y > rect.y && p.y < rect.y + rect.h) return 'move';
|
||||
return 'new';
|
||||
}
|
||||
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
||||
canvas.addEventListener('pointerdown', function (e) {
|
||||
if (!src) return;
|
||||
var p = toImg(e);
|
||||
var mode = hit(p);
|
||||
drag = { mode: mode, sx: p.x, sy: p.y, r0: { x: rect.x, y: rect.y, w: rect.w, h: rect.h } };
|
||||
if (mode === 'new') { // nuovo rettangolo: parte dal punto di tocco
|
||||
drag.r0 = { x: clamp(p.x, 0, sw()), y: clamp(p.y, 0, sh()), w: 0, h: 0 };
|
||||
}
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
canvas.addEventListener('pointermove', function (e) {
|
||||
if (!src) return;
|
||||
var p = toImg(e);
|
||||
if (!drag) {
|
||||
var m = hit(p);
|
||||
canvas.style.cursor = m === 'move' ? 'move' : m === 'new' ? 'crosshair'
|
||||
: (m.length === 2 ? (m === 'nw' || m === 'se' ? 'nwse-resize' : 'nesw-resize')
|
||||
: (m === 'n' || m === 's' ? 'ns-resize' : 'ew-resize'));
|
||||
return;
|
||||
}
|
||||
var dx = p.x - drag.sx, dy = p.y - drag.sy, r0 = drag.r0, W = sw(), H = sh();
|
||||
if (drag.mode === 'move') {
|
||||
rect.x = clamp(r0.x + dx, 0, W - r0.w);
|
||||
rect.y = clamp(r0.y + dy, 0, H - r0.h);
|
||||
} else {
|
||||
var x1 = r0.x, y1 = r0.y, x2 = r0.x + r0.w, y2 = r0.y + r0.h, m2 = drag.mode;
|
||||
if (m2 === 'new') { x2 = clamp(p.x, 0, W); y2 = clamp(p.y, 0, H); }
|
||||
else {
|
||||
if (m2.indexOf('w') >= 0) x1 = clamp(r0.x + dx, 0, x2 - MIN_SIDE);
|
||||
if (m2.indexOf('e') >= 0) x2 = clamp(x2 + dx, x1 + MIN_SIDE, W);
|
||||
if (m2.indexOf('n') >= 0) y1 = clamp(r0.y + dy, 0, y2 - MIN_SIDE);
|
||||
if (m2.indexOf('s') >= 0) y2 = clamp(y2 + dy, y1 + MIN_SIDE, H);
|
||||
}
|
||||
rect = { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
|
||||
}
|
||||
bOk.disabled = rect.w < MIN_SIDE || rect.h < MIN_SIDE; // ritaglio troppo piccolo: non confermabile
|
||||
draw();
|
||||
e.preventDefault();
|
||||
});
|
||||
function up() { drag = null; }
|
||||
canvas.addEventListener('pointerup', up);
|
||||
canvas.addEventListener('pointercancel', up);
|
||||
|
||||
// ---- caricamento sorgente ----
|
||||
function fail(err) {
|
||||
console.error('ImageCrop:', err);
|
||||
status.style.display = '';
|
||||
status.textContent = L.error;
|
||||
canvas.style.display = 'none';
|
||||
bOk.disabled = true;
|
||||
}
|
||||
if (isPdf(file)) {
|
||||
loadPdfJs().then(function (lib) {
|
||||
if (cfg.workerSrc) lib.GlobalWorkerOptions.workerSrc = cfg.workerSrc;
|
||||
return file.arrayBuffer().then(function (buf) { return lib.getDocument({ data: buf }).promise; });
|
||||
}).then(function (pdf) {
|
||||
pdfDoc = pdf;
|
||||
if (pdf.numPages > 1) {
|
||||
for (var i = 1; i <= pdf.numPages; i++) {
|
||||
var o = el('option', '', String(i)); o.value = String(i); pageSel.appendChild(o);
|
||||
}
|
||||
pageWrap.style.display = 'flex';
|
||||
}
|
||||
return renderPdfPage(pdf, 1);
|
||||
}).then(setSource).catch(fail);
|
||||
pageSel.onchange = function () {
|
||||
bOk.disabled = true;
|
||||
renderPdfPage(pdfDoc, parseInt(pageSel.value, 10)).then(setSource).catch(fail);
|
||||
};
|
||||
} else {
|
||||
readImage(file).then(setSource).catch(fail);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
global.ImageCrop = { open: open, configure: configure };
|
||||
})(window);
|
||||
@@ -0,0 +1,60 @@
|
||||
/* Login Operatore: nome + PIN a 4 cifre (#59).
|
||||
* Componente Alpine globale `pinLogin()`, usato da templates/auth/login.html.
|
||||
* Contratto: GET /auth/pin-users -> [{id, display_name}];
|
||||
* POST /auth/login-pin {user_id, pin} -> {ok, redirect} | {error, detail}. */
|
||||
function pinLogin() {
|
||||
return {
|
||||
mode: 'pin', // vista Operatore di default; se l'elenco e' vuoto/fallisce passa a 'password'
|
||||
users: [],
|
||||
selected: null,
|
||||
pin: '',
|
||||
error: '',
|
||||
busy: false,
|
||||
|
||||
init: function () {
|
||||
var self = this;
|
||||
fetch(window.TMF_PIN.usersUrl, { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : []; })
|
||||
.then(function (list) {
|
||||
self.users = Array.isArray(list) ? list : [];
|
||||
if (!self.users.length) self.mode = 'password'; // elenco vuoto: form password
|
||||
})
|
||||
.catch(function () { self.users = []; self.mode = 'password'; });
|
||||
},
|
||||
select: function (u) { this.selected = u; this.pin = ''; this.error = ''; },
|
||||
back: function () { this.selected = null; this.pin = ''; this.error = ''; },
|
||||
del: function () { this.pin = this.pin.slice(0, -1); },
|
||||
press: function (d) {
|
||||
if (this.busy || this.pin.length >= 4) return;
|
||||
this.pin += d;
|
||||
this.error = '';
|
||||
if (this.pin.length === 4) this.submit(); // invio automatico alla quarta cifra
|
||||
},
|
||||
submit: function () {
|
||||
var self = this;
|
||||
var csrf = document.querySelector('meta[name=csrf-token]');
|
||||
self.busy = true;
|
||||
fetch(window.TMF_PIN.loginUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRFToken': (csrf && csrf.content) || ''
|
||||
},
|
||||
body: JSON.stringify({ user_id: self.selected.id, pin: self.pin })
|
||||
})
|
||||
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
||||
.then(function (d) {
|
||||
if (d.ok && d.redirect) { window.location.href = d.redirect; return; }
|
||||
self.error = d.detail || window.TMF_PIN.msgNet; // 401 PIN errato, 423 blocco con ora
|
||||
self.pin = '';
|
||||
self.busy = false;
|
||||
})
|
||||
.catch(function () {
|
||||
self.error = window.TMF_PIN.msgNet;
|
||||
self.pin = '';
|
||||
self.busy = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Task results (#53 #54 #55) - Alpine component for the tasks that record a result.
|
||||
*
|
||||
* - «check» task (#54): the operator answers OK or NOK, with a note (mandatory for NOK).
|
||||
* - «note» task with `fields` (#55): one input per field, an optional attachment
|
||||
* (image / PDF) for the fields that ask for it, outcome 'done'.
|
||||
*
|
||||
* The same component serves the task being executed (one form) and the panel shown
|
||||
* at the end of production (one form per at_close task), so the field rules live here
|
||||
* once. The page sets `window.__taskResultsCfg` (urls + labels) and the component's
|
||||
* `ctx` (production run id + callbacks) from its x-init, the way visionExecute() does.
|
||||
*
|
||||
* Plain functions only (no framework imports) so tests/js/test_task_results.js can
|
||||
* run them under node with a stubbed fetch.
|
||||
*/
|
||||
function taskResultForm() {
|
||||
return {
|
||||
// Set by the page's x-init: () => ({ runId: int|null, done: () => void })
|
||||
ctx: null,
|
||||
forms: [],
|
||||
working: false,
|
||||
error: '',
|
||||
// true once a result was recorded during this page view (shows «Prosegui»)
|
||||
justRecorded: false,
|
||||
// Panel open state (used by the end-of-production panel only)
|
||||
open: false,
|
||||
|
||||
cfg() { return window.__taskResultsCfg || { labels: {} }; },
|
||||
label(key, fallback) {
|
||||
return (this.cfg().labels && this.cfg().labels[key]) || fallback;
|
||||
},
|
||||
runId() {
|
||||
const c = this.ctx ? this.ctx() : null;
|
||||
return c && c.runId ? c.runId : null;
|
||||
},
|
||||
|
||||
/* One form per task: values start from the field's default, the way the maker set it. */
|
||||
makeForm(task) {
|
||||
const fields = Array.isArray(task.fields) ? task.fields : [];
|
||||
const values = {};
|
||||
fields.forEach((f) => { values[f.key] = (f.default === null || f.default === undefined) ? '' : String(f.default); });
|
||||
return {
|
||||
task_id: task.id,
|
||||
title: task.title || '',
|
||||
directive: task.directive || '',
|
||||
type: task.task_type,
|
||||
fields: fields,
|
||||
values: values,
|
||||
files: {},
|
||||
fileNames: {},
|
||||
note: '',
|
||||
outcome: '',
|
||||
error: '',
|
||||
result: null,
|
||||
recorded: false,
|
||||
};
|
||||
},
|
||||
|
||||
/* The single form of the task on screen. */
|
||||
initForTask(task) {
|
||||
if (!task) return;
|
||||
const isCheck = task.task_type === 'check';
|
||||
const isNoteWithFields = task.task_type === 'note' && Array.isArray(task.fields) && task.fields.length > 0;
|
||||
if (!isCheck && !isNoteWithFields) return;
|
||||
this.forms = [this.makeForm(task)];
|
||||
this.loadLast();
|
||||
},
|
||||
|
||||
pickFile(form, key, ev) {
|
||||
const f = ev && ev.target && ev.target.files ? ev.target.files[0] : null;
|
||||
if (f) { form.files[key] = f; form.fileNames[key] = f.name; }
|
||||
else { delete form.files[key]; form.fileNames[key] = ''; }
|
||||
},
|
||||
|
||||
csrf() {
|
||||
const m = typeof document !== 'undefined' ? document.querySelector('meta[name=csrf-token]') : null;
|
||||
return (m && m.content) || '';
|
||||
},
|
||||
|
||||
/* Fetch + JSON with the page's usual error reading: the detail of the server is
|
||||
what the operator sees; an HTML answer (expired CSRF / session) means «reload». */
|
||||
async call(url, opts) {
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(url, opts);
|
||||
} catch (e) {
|
||||
return { ok: false, detail: this.label('connection', 'Errore di connessione') };
|
||||
}
|
||||
const data = await resp.json().catch(() => null);
|
||||
if (!data || data.csrf_expired || data.session_expired) {
|
||||
return { ok: false, detail: this.label('sessionExpired', 'Sessione scaduta: ricaricare la pagina') };
|
||||
}
|
||||
if (!resp.ok) {
|
||||
return { ok: false, detail: data.detail || this.label('serverError', 'Errore di comunicazione con il server') };
|
||||
}
|
||||
return { ok: true, data: data };
|
||||
},
|
||||
|
||||
/* The last result of this task: in the run under way if there is one, otherwise
|
||||
the one given in this browser session (no run = nothing on the server to ask).
|
||||
Only shown: the buttons stay active, a loop gives a new result every cycle. */
|
||||
async loadLast() {
|
||||
const form = this.forms[0];
|
||||
if (!form) return;
|
||||
const rid = this.runId();
|
||||
if (rid && this.cfg().listUrl) {
|
||||
const r = await this.call(
|
||||
this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid) +
|
||||
'&task_id=' + encodeURIComponent(form.task_id));
|
||||
if (r.ok && Array.isArray(r.data) && r.data.length) {
|
||||
form.result = r.data[r.data.length - 1];
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem('tmf-task-result-' + form.task_id);
|
||||
if (raw && !rid) form.result = JSON.parse(raw);
|
||||
} catch (e) { /* storage may be unavailable: the result is simply not shown */ }
|
||||
},
|
||||
|
||||
/* Validation before anything goes on the wire. Returns the message or ''. */
|
||||
validate(form, outcome) {
|
||||
if (form.type === 'check') {
|
||||
if (outcome === 'nok' && !(form.note || '').trim()) {
|
||||
return this.label('nokNeedsNote', 'Per un NOK serve la nota');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
for (const f of form.fields) {
|
||||
const v = form.values[f.key];
|
||||
if (f.type === 'number' && v !== '' && v !== null && v !== undefined &&
|
||||
!isFinite(Number(String(v).replace(',', '.')))) {
|
||||
return this.label('notANumber', 'Valore non numerico') + ': ' + f.label;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
/* Upload the attachments of a form, then POST the result.
|
||||
Returns true when the result is recorded (forms already recorded are skipped). */
|
||||
async record(form, outcome) {
|
||||
if (form.recorded) return true;
|
||||
form.error = this.validate(form, outcome);
|
||||
if (form.error) return false;
|
||||
|
||||
const attachments = [];
|
||||
for (const f of form.fields) {
|
||||
const file = form.files[f.key];
|
||||
if (!f.attachment || !file) continue;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file, file.name);
|
||||
const up = await this.call(this.cfg().uploadUrl, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': this.csrf() }, body: fd,
|
||||
});
|
||||
if (!up.ok) { form.error = up.detail; return false; }
|
||||
// The backend answers {file_path, ...}: that path is what the result keeps.
|
||||
attachments.push({ key: f.key, path: up.data.file_path || up.data.path, name: file.name });
|
||||
}
|
||||
|
||||
let values = null;
|
||||
if (form.type === 'note' && form.fields.length) {
|
||||
values = {};
|
||||
form.fields.forEach((f) => {
|
||||
const v = form.values[f.key];
|
||||
if (v === '' || v === null || v === undefined) return;
|
||||
values[f.key] = f.type === 'number' ? String(v).replace(',', '.') : String(v);
|
||||
});
|
||||
}
|
||||
|
||||
const body = {
|
||||
task_id: form.task_id,
|
||||
production_run_id: this.runId(),
|
||||
outcome: form.type === 'check' ? outcome : 'done',
|
||||
note: (form.note || '').trim() || null,
|
||||
values: values,
|
||||
attachments: attachments.length ? attachments : null,
|
||||
};
|
||||
const r = await this.call(this.cfg().saveUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrf() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) { form.error = r.detail; return false; }
|
||||
form.result = r.data;
|
||||
form.recorded = true;
|
||||
try {
|
||||
window.sessionStorage.setItem('tmf-task-result-' + form.task_id, JSON.stringify(r.data));
|
||||
} catch (e) { /* storage may be unavailable */ }
|
||||
return true;
|
||||
},
|
||||
|
||||
/* Task on screen: OK / NOK button, or «Completato» of a note with fields. */
|
||||
async submit(outcome) {
|
||||
const form = this.forms[0];
|
||||
if (!form || this.working) return;
|
||||
this.working = true;
|
||||
this.error = '';
|
||||
const ok = await this.record(form, outcome);
|
||||
this.working = false;
|
||||
if (!ok) return;
|
||||
this.justRecorded = true;
|
||||
this.proceed();
|
||||
},
|
||||
|
||||
/* Hand over to the page: close the cycle / go on. Also bound to «Prosegui», for
|
||||
when that step failed after the result was already recorded. */
|
||||
proceed() {
|
||||
const c = this.ctx ? this.ctx() : null;
|
||||
if (c && c.done) c.done();
|
||||
},
|
||||
|
||||
/* The result as the operator reads it. */
|
||||
resultLabel(result) {
|
||||
if (!result) return '';
|
||||
if (result.outcome === 'ok') return this.label('outcomeOk', 'OK');
|
||||
if (result.outcome === 'nok') return this.label('outcomeNok', 'NOK');
|
||||
return this.label('outcomeDone', 'Registrato');
|
||||
},
|
||||
|
||||
// ---- End of production panel (#55) ----
|
||||
|
||||
/* Open the panel for the at_close tasks of the run. A task that already has a
|
||||
result in this run (page reloaded in between) is not asked again. */
|
||||
async openClose(ids) {
|
||||
this.error = '';
|
||||
this.working = true;
|
||||
this.open = true;
|
||||
this.forms = [];
|
||||
const rid = this.runId();
|
||||
let done = [];
|
||||
if (rid && this.cfg().listUrl) {
|
||||
const r = await this.call(this.cfg().listUrl + '?production_run_id=' + encodeURIComponent(rid));
|
||||
if (r.ok && Array.isArray(r.data)) done = r.data.map((x) => x.task_id);
|
||||
}
|
||||
const forms = [];
|
||||
for (const id of ids) {
|
||||
const r = await this.call(this.cfg().taskUrlTpl.replace('/0', '/' + id));
|
||||
if (!r.ok) { this.error = r.detail; this.working = false; return; }
|
||||
const form = this.makeForm(r.data);
|
||||
if (done.indexOf(id) !== -1) form.recorded = true;
|
||||
forms.push(form);
|
||||
}
|
||||
this.forms = forms;
|
||||
this.working = false;
|
||||
// Nothing left to ask: straight on to the capoturno.
|
||||
if (forms.every((f) => f.recorded)) this.finishClose();
|
||||
},
|
||||
|
||||
cancelClose() { this.open = false; this.forms = []; this.error = ''; },
|
||||
|
||||
async submitClose() {
|
||||
if (this.working) return;
|
||||
this.working = true;
|
||||
this.error = '';
|
||||
let allOk = true;
|
||||
for (const form of this.forms) {
|
||||
// Keep going after a failure: each form shows its own error, and the ones
|
||||
// already recorded are not sent again on the next try.
|
||||
if (!(await this.record(form, 'done'))) allOk = false;
|
||||
}
|
||||
this.working = false;
|
||||
if (allOk) this.finishClose();
|
||||
},
|
||||
|
||||
finishClose() {
|
||||
this.open = false;
|
||||
this.proceed();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = { taskResultForm };
|
||||
@@ -0,0 +1,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 || []); }
|
||||
};
|
||||
}
|
||||
+132
-219
@@ -1,18 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Pannello utenti: incluso nella tab «Utenti» di Impostazioni (#50) #}
|
||||
<script>window.__users = {{ users|tojson }};</script>
|
||||
|
||||
<div class="tmf-page"
|
||||
x-data="userManagement(window.__users)">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Utenti') }}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci gli utenti del sistema') }}</p>
|
||||
</div>
|
||||
<div x-data="userManagement(window.__users)">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
||||
@@ -66,6 +55,10 @@
|
||||
<template x-if="user.is_admin">
|
||||
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">Admin</span>
|
||||
</template>
|
||||
<!-- #59: indicatore PIN impostato (solo se il backend manda has_pin) -->
|
||||
<template x-if="!user.is_admin && user.has_pin === true">
|
||||
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300">{{ _('PIN impostato') }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -102,6 +95,16 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- #51: reset password dall'admin -->
|
||||
<button @click="openReset(user)" class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="{{ _('Reset password')|tojson_attr }}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- #59: imposta/reset PIN (non per gli admin: il backend risponde 422) -->
|
||||
<template x-if="!user.is_admin">
|
||||
<button @click="openPin(user)" class="px-1.5 py-1 rounded-lg text-[11px] font-semibold text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="user.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}">PIN</button>
|
||||
</template>
|
||||
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
|
||||
:class="user.active
|
||||
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
@@ -215,6 +218,20 @@
|
||||
minlength="6">
|
||||
</div>
|
||||
|
||||
<!-- #59: PIN facoltativo, solo in creazione di un non admin -->
|
||||
<template x-if="!isEditing && !form.is_admin">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
{{ _('PIN') }}
|
||||
<span class="font-normal text-[var(--text-secondary)]"> ({{ _('facoltativo, 4 cifre') }})</span>
|
||||
</label>
|
||||
<input type="text" x-model="form.pin" inputmode="numeric" maxlength="4" autocomplete="off"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="1234">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Roles -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
|
||||
@@ -332,210 +349,106 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #51: Modal reset password (la password attuale non si mostra mai) -->
|
||||
<div x-show="showReset" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="closeReset()">
|
||||
<div class="absolute inset-0 bg-black/50" @click="closeReset()"></div>
|
||||
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{{ _('Reset password') }}: <span x-text="resetUser?.username"></span>
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
{{ _('Le sessioni aperte dell\'utente verranno chiuse e dovrà accedere con la nuova password.') }}
|
||||
</p>
|
||||
<template x-if="!resetDone">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuova password (minimo 8 caratteri)') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" x-model="resetPw" autocomplete="off"
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
|
||||
<button @click="generateResetPw()" type="button"
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
|
||||
{{ _('Genera') }}
|
||||
</button>
|
||||
</div>
|
||||
<template x-if="resetError">
|
||||
<p class="mt-2 text-sm text-red-600" x-text="resetError"></p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="resetDone">
|
||||
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
|
||||
<p class="text-sm text-emerald-700 dark:text-emerald-300">{{ _('Password cambiata. Annotala ora: non verrà mostrata di nuovo.') }}</p>
|
||||
<p class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="resetPw"></p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex justify-end gap-3 mt-5">
|
||||
<button @click="closeReset()"
|
||||
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
|
||||
<span x-text="resetDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
|
||||
</button>
|
||||
<button x-show="!resetDone" @click="confirmReset()" :disabled="saving"
|
||||
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
|
||||
{{ _('Imposta password') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #59: Modal imposta / reset / togli PIN (il PIN attuale non si mostra mai) -->
|
||||
<div x-show="showPin" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="closePin()">
|
||||
<div class="absolute inset-0 bg-black/50" @click="closePin()"></div>
|
||||
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
<span x-text="pinUser?.has_pin ? {{ _('Reset PIN')|tojson_attr }} : {{ _('Imposta PIN')|tojson_attr }}"></span>: <span x-text="pinUser?.username"></span>
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
{{ _('Il PIN serve per accedere alla postazione e, per il capoturno, per autorizzare le azioni.') }}
|
||||
</p>
|
||||
<template x-if="!pinDone">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuovo PIN (4 cifre)') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" x-model="pinValue" inputmode="numeric" maxlength="4" autocomplete="off"
|
||||
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
|
||||
<button @click="generatePin()" type="button"
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
|
||||
{{ _('Genera') }}
|
||||
</button>
|
||||
</div>
|
||||
<template x-if="pinError">
|
||||
<p class="mt-2 text-sm text-red-600" x-text="pinError"></p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="pinDone">
|
||||
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
|
||||
<p class="text-sm text-emerald-700 dark:text-emerald-300" x-text="pinRemoved ? {{ _('PIN tolto.')|tojson_attr }} : {{ _('PIN impostato. Annotalo ora: non verrà mostrato di nuovo.')|tojson_attr }}"></p>
|
||||
<p x-show="!pinRemoved" class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="pinValue"></p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex items-center justify-between gap-3 mt-5">
|
||||
<div>
|
||||
<button x-show="!pinDone && pinUser?.has_pin" @click="removePin()" :disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium text-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50">
|
||||
{{ _('Togli PIN') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button @click="closePin()"
|
||||
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
|
||||
<span x-text="pinDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
|
||||
</button>
|
||||
<button x-show="!pinDone" @click="confirmPin()" :disabled="saving"
|
||||
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
|
||||
{{ _('Imposta PIN') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function userManagement(initialUsers) {
|
||||
return {
|
||||
users: initialUsers || [],
|
||||
search: '',
|
||||
showModal: false,
|
||||
showToggleConfirm: false,
|
||||
isEditing: false,
|
||||
editingUserId: null,
|
||||
toggleUser: null,
|
||||
saving: false,
|
||||
errorMsg: '',
|
||||
form: {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
},
|
||||
|
||||
get csrfToken() {
|
||||
const meta = document.querySelector('meta[name=csrf-token]');
|
||||
return meta ? meta.content : '';
|
||||
},
|
||||
|
||||
get filteredUsers() {
|
||||
if (!this.search.trim()) return this.users;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.users.filter(u =>
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
|
||||
(u.email && u.email.toLowerCase().includes(q)) ||
|
||||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
|
||||
);
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
this.isEditing = false;
|
||||
this.editingUserId = null;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
openEditModal(user) {
|
||||
this.isEditing = true;
|
||||
this.editingUserId = user.id;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: user.username,
|
||||
display_name: user.display_name || '',
|
||||
email: user.email || '',
|
||||
password: '',
|
||||
roles: [...(user.roles || [])],
|
||||
is_admin: user.is_admin || false,
|
||||
language_pref: user.language_pref || 'it',
|
||||
theme_pref: user.theme_pref || 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.errorMsg = '';
|
||||
},
|
||||
|
||||
async saveUser() {
|
||||
this.saving = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
try {
|
||||
if (this.isEditing) {
|
||||
// Update user
|
||||
const data = {
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nel salvataggio';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.editingUserId);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
// Change password if provided
|
||||
if (this.form.password) {
|
||||
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ password: this.form.password })
|
||||
});
|
||||
if (!pwResp.ok) {
|
||||
const pwResult = await pwResp.json();
|
||||
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
if (!this.form.username || !this.form.display_name || !this.form.password) {
|
||||
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
username: this.form.username,
|
||||
password: this.form.password,
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch('/admin/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nella creazione';
|
||||
return;
|
||||
}
|
||||
|
||||
this.users.push(result);
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
toggleActive(user) {
|
||||
this.toggleUser = user;
|
||||
this.showToggleConfirm = true;
|
||||
},
|
||||
|
||||
async confirmToggle() {
|
||||
if (!this.toggleUser) return;
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const newActive = !this.toggleUser.active;
|
||||
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ active: newActive })
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
alert(result.detail || 'Errore');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
this.showToggleConfirm = false;
|
||||
this.toggleUser = null;
|
||||
} catch (e) {
|
||||
alert('{{ _("Errore di connessione al server") }}');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,355 @@
|
||||
<script>
|
||||
function userManagement(initialUsers) {
|
||||
return {
|
||||
users: initialUsers || [],
|
||||
search: '',
|
||||
showModal: false,
|
||||
showToggleConfirm: false,
|
||||
// #51: stato del reset password
|
||||
showReset: false,
|
||||
resetUser: null,
|
||||
resetPw: '',
|
||||
resetDone: false,
|
||||
resetError: '',
|
||||
// #59: stato del modal PIN
|
||||
showPin: false,
|
||||
pinUser: null,
|
||||
pinValue: '',
|
||||
pinDone: false,
|
||||
pinRemoved: false,
|
||||
pinError: '',
|
||||
isEditing: false,
|
||||
editingUserId: null,
|
||||
toggleUser: null,
|
||||
saving: false,
|
||||
errorMsg: '',
|
||||
form: {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
pin: '', // #59: PIN facoltativo alla creazione
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
},
|
||||
|
||||
get csrfToken() {
|
||||
const meta = document.querySelector('meta[name=csrf-token]');
|
||||
return meta ? meta.content : '';
|
||||
},
|
||||
|
||||
get filteredUsers() {
|
||||
if (!this.search.trim()) return this.users;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.users.filter(u =>
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
|
||||
(u.email && u.email.toLowerCase().includes(q)) ||
|
||||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
|
||||
);
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
this.isEditing = false;
|
||||
this.editingUserId = null;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
pin: '', // #59
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
openEditModal(user) {
|
||||
this.isEditing = true;
|
||||
this.editingUserId = user.id;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: user.username,
|
||||
display_name: user.display_name || '',
|
||||
email: user.email || '',
|
||||
password: '',
|
||||
roles: [...(user.roles || [])],
|
||||
is_admin: user.is_admin || false,
|
||||
language_pref: user.language_pref || 'it',
|
||||
theme_pref: user.theme_pref || 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.errorMsg = '';
|
||||
},
|
||||
|
||||
async saveUser() {
|
||||
this.saving = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
try {
|
||||
if (this.isEditing) {
|
||||
// Update user
|
||||
const data = {
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nel salvataggio';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.editingUserId);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
// Change password if provided
|
||||
if (this.form.password) {
|
||||
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ password: this.form.password })
|
||||
});
|
||||
if (!pwResp.ok) {
|
||||
const pwResult = await pwResp.json();
|
||||
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
if (!this.form.username || !this.form.display_name || !this.form.password) {
|
||||
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
|
||||
return;
|
||||
}
|
||||
|
||||
// #59: PIN facoltativo, 4 cifre, solo per i non admin
|
||||
const pin = this.form.is_admin ? '' : (this.form.pin || '').trim();
|
||||
if (pin && !/^\d{4}$/.test(pin)) {
|
||||
this.errorMsg = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
|
||||
return;
|
||||
}
|
||||
const data = {
|
||||
username: this.form.username,
|
||||
password: this.form.password,
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch('/admin/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nella creazione';
|
||||
return;
|
||||
}
|
||||
|
||||
this.users.push(result);
|
||||
|
||||
// #59: l'utente esiste gia', il PIN si imposta con la chiamata dedicata
|
||||
if (pin) {
|
||||
const pinResp = await fetch(`/admin/api/users/${result.id}/pin`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ pin: pin })
|
||||
});
|
||||
if (!pinResp.ok) {
|
||||
const pinResult = await pinResp.json().catch(() => ({}));
|
||||
// resta aperto in modifica sull'utente appena creato, cosi' non si ricrea
|
||||
this.isEditing = true;
|
||||
this.editingUserId = result.id;
|
||||
this.errorMsg = {{ _('Utente creato, ma PIN non impostato')|tojson }} + ': ' + (pinResult.detail || '');
|
||||
return;
|
||||
}
|
||||
result.has_pin = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// #51: reset password dall'admin, usa PUT /admin/api/users/<id>/password
|
||||
openReset(user) {
|
||||
this.resetUser = user;
|
||||
this.resetPw = '';
|
||||
this.resetDone = false;
|
||||
this.resetError = '';
|
||||
this.showReset = true;
|
||||
},
|
||||
|
||||
closeReset() {
|
||||
this.showReset = false;
|
||||
this.resetPw = ''; // la password generata non resta in memoria
|
||||
this.resetDone = false;
|
||||
},
|
||||
|
||||
generateResetPw() {
|
||||
// 12 caratteri senza simboli ambigui (0/O, 1/l/I), da crypto random
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||
const buf = new Uint32Array(12);
|
||||
crypto.getRandomValues(buf);
|
||||
this.resetPw = Array.from(buf, n => chars[n % chars.length]).join('');
|
||||
this.resetError = '';
|
||||
},
|
||||
|
||||
async confirmReset() {
|
||||
if (!this.resetPw || this.resetPw.length < 8) {
|
||||
this.resetError = {{ _('La password deve avere almeno 8 caratteri')|tojson }};
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.resetError = '';
|
||||
try {
|
||||
const resp = await fetch(`/admin/api/users/${this.resetUser.id}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ password: this.resetPw })
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.resetError = result.detail || {{ _('Errore nel cambio password')|tojson }};
|
||||
return;
|
||||
}
|
||||
this.resetDone = true;
|
||||
} catch (e) {
|
||||
this.resetError = {{ _('Errore di connessione al server')|tojson }};
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// #59: imposta / reset / togli PIN, usa PUT /admin/api/users/<id>/pin
|
||||
openPin(user) {
|
||||
this.pinUser = user;
|
||||
this.pinValue = '';
|
||||
this.pinDone = false;
|
||||
this.pinRemoved = false;
|
||||
this.pinError = '';
|
||||
this.showPin = true;
|
||||
},
|
||||
|
||||
closePin() {
|
||||
this.showPin = false;
|
||||
this.pinValue = ''; // il PIN generato non resta in memoria
|
||||
this.pinDone = false;
|
||||
},
|
||||
|
||||
generatePin() {
|
||||
// 4 cifre da crypto random
|
||||
const buf = new Uint32Array(4);
|
||||
crypto.getRandomValues(buf);
|
||||
this.pinValue = Array.from(buf, n => n % 10).join('');
|
||||
this.pinError = '';
|
||||
},
|
||||
|
||||
async savePin(pin) {
|
||||
// pin = stringa di 4 cifre oppure null (toglie il PIN)
|
||||
this.saving = true;
|
||||
this.pinError = '';
|
||||
try {
|
||||
const resp = await fetch(`/admin/api/users/${this.pinUser.id}/pin`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ pin: pin })
|
||||
});
|
||||
const result = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
this.pinError = result.detail || {{ _('Errore nel salvataggio del PIN')|tojson }};
|
||||
return;
|
||||
}
|
||||
// aggiorna l'indicatore locale
|
||||
const idx = this.users.findIndex(u => u.id === this.pinUser.id);
|
||||
if (idx >= 0) this.users[idx] = Object.assign({}, this.users[idx], { has_pin: pin !== null });
|
||||
this.pinUser = idx >= 0 ? this.users[idx] : this.pinUser;
|
||||
this.pinRemoved = pin === null;
|
||||
this.pinDone = true;
|
||||
} catch (e) {
|
||||
this.pinError = {{ _('Errore di connessione al server')|tojson }};
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async confirmPin() {
|
||||
if (!/^\d{4}$/.test(this.pinValue)) {
|
||||
this.pinError = {{ _('Il PIN deve essere di 4 cifre')|tojson }};
|
||||
return;
|
||||
}
|
||||
await this.savePin(this.pinValue);
|
||||
},
|
||||
|
||||
async removePin() {
|
||||
await this.savePin(null);
|
||||
},
|
||||
|
||||
toggleActive(user) {
|
||||
this.toggleUser = user;
|
||||
this.showToggleConfirm = true;
|
||||
},
|
||||
|
||||
async confirmToggle() {
|
||||
if (!this.toggleUser) return;
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const newActive = !this.toggleUser.active;
|
||||
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ active: newActive })
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
alert(result.detail || 'Errore');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
this.showToggleConfirm = false;
|
||||
this.toggleUser = null;
|
||||
} catch (e) {
|
||||
alert('{{ _("Errore di connessione al server") }}');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -3,17 +3,32 @@
|
||||
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="tmf-page tmf-page-narrow"
|
||||
x-data="systemSettings()">
|
||||
<div class="tmf-page" x-data="{ tab: {{ active_tab|tojson_attr }} }">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- #50: tab. L'intera pagina e' gia' solo per admin (admin_required) -->
|
||||
<div class="flex gap-1 mb-6 border-b border-[var(--border-color)]">
|
||||
<button type="button" @click="tab = 'generale'"
|
||||
:class="tab === 'generale' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
|
||||
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Generale') }}</button>
|
||||
<button type="button" @click="tab = 'utenti'"
|
||||
:class="tab === 'utenti' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
|
||||
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Utenti') }}</button>
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'utenti'" x-cloak>
|
||||
{% include "admin/_users_panel.html" %}
|
||||
</div>
|
||||
|
||||
{# #65: niente larghezza propria, la scheda sta nella cornice .tmf-page #}
|
||||
<div x-show="tab === 'generale'" x-data="systemSettings()">
|
||||
<!-- Settings Card -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card mb-6">
|
||||
<div class="tmf-card-header flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
@@ -43,6 +58,57 @@
|
||||
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- #47: durata loop predefinita delle ricette -->
|
||||
<div class="pt-5 border-t border-[var(--border-color)]">
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Ricette') }}</h3>
|
||||
<label class="tmf-label" for="default-interval">{{ _('Durata loop predefinita (minuti)') }}</label>
|
||||
<input id="default-interval" type="number" x-model="defaultInterval" class="tmf-input w-32" min="1" max="1440" placeholder="—">
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Proposta come intervallo di misura nelle nuove ricette (1-1440). Vuoto = nessun predefinito.') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- #48: tolleranze generali -->
|
||||
<div class="pt-5 border-t border-[var(--border-color)]">
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Tolleranze generali') }}</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<div>
|
||||
<label class="tmf-label" for="tol-plus">{{ _('Tolleranza +') }}</label>
|
||||
<input id="tol-plus" type="text" inputmode="decimal" x-model="tolPlus" class="tmf-input w-32" placeholder="—">
|
||||
</div>
|
||||
<div>
|
||||
<label class="tmf-label" for="tol-minus">{{ _('Tolleranza −') }}</label>
|
||||
<input id="tol-minus" type="text" inputmode="decimal" x-model="tolMinus" class="tmf-input w-32" placeholder="—">
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Valori positivi (zero o più) proposti come tolleranza nelle nuove misure. Vuoto = nessun predefinito.') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- #49: anagrafiche (liste di scelta) -->
|
||||
<div class="pt-5 border-t border-[var(--border-color)]">
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Anagrafiche') }}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<template x-for="lst in ['types', 'units']" :key="lst">
|
||||
<div>
|
||||
<label class="tmf-label" x-text="lst === 'types' ? {{ _('Tipi di misura')|tojson_attr }} : {{ _('Unità di misura')|tojson_attr }}"></label>
|
||||
<ul class="space-y-1 mb-2">
|
||||
<template x-for="(item, i) in lists[lst]" :key="lst + i">
|
||||
<li class="flex items-center gap-1 text-sm">
|
||||
<span class="flex-1 px-2 py-1 rounded bg-[var(--bg-secondary)] text-[var(--text-primary)]" x-text="item"></span>
|
||||
<button type="button" @click="moveItem(lst, i, -1)" :disabled="i === 0" class="px-1.5 text-[var(--text-muted)] hover:text-primary disabled:opacity-30" title="{{ _('Su') }}">▲</button>
|
||||
<button type="button" @click="moveItem(lst, i, 1)" :disabled="i === lists[lst].length - 1" class="px-1.5 text-[var(--text-muted)] hover:text-primary disabled:opacity-30" title="{{ _('Giù') }}">▼</button>
|
||||
<button type="button" @click="removeItem(lst, i)" class="px-1.5 text-[var(--text-muted)] hover:text-red-500" title="{{ _('Rimuovi') }}">✕</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" x-model="newItem[lst]" @keydown.enter.prevent="addItem(lst)" class="tmf-input flex-1" placeholder="{{ _('Nuova voce') }}">
|
||||
<button type="button" @click="addItem(lst)" class="btn btn-secondary">{{ _('Aggiungi') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-2">{{ _('Massimo 50 voci per lista. Un elenco vuoto = nessuna scelta predefinita.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tmf-card-footer flex items-center justify-between">
|
||||
@@ -68,18 +134,48 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab generale -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// #49: legge un array JSON di stringhe salvato come impostazione; vuoto/malformato = lista vuota
|
||||
function tmfParseList(raw) {
|
||||
try { var a = JSON.parse(raw || '[]'); return Array.isArray(a) ? a.map(String) : []; }
|
||||
catch (e) { return []; }
|
||||
}
|
||||
function systemSettings() {
|
||||
return {
|
||||
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
|
||||
// #47-#49: nuove chiavi (valori stringa, vuoto = nessun default)
|
||||
defaultInterval: {{ (settings.default_measurement_interval_minutes or '')|tojson }},
|
||||
tolPlus: {{ (settings.general_tolerance_plus or '')|tojson }},
|
||||
tolMinus: {{ (settings.general_tolerance_minus or '')|tojson }},
|
||||
lists: {
|
||||
types: tmfParseList({{ (settings.list_measurement_types or '')|tojson }}),
|
||||
units: tmfParseList({{ (settings.list_units or '')|tojson }})
|
||||
},
|
||||
newItem: { types: '', units: '' },
|
||||
saving: false,
|
||||
saved: false,
|
||||
errorMessage: '',
|
||||
|
||||
addItem(lst) {
|
||||
var v = (this.newItem[lst] || '').trim();
|
||||
if (!v) return;
|
||||
if (this.lists[lst].length >= 50) { this.errorMessage = {{ _('Massimo 50 voci per lista')|tojson }}; return; }
|
||||
if (this.lists[lst].indexOf(v) === -1) this.lists[lst].push(v);
|
||||
this.newItem[lst] = '';
|
||||
},
|
||||
removeItem(lst, i) { this.lists[lst].splice(i, 1); },
|
||||
moveItem(lst, i, d) {
|
||||
var a = this.lists[lst].slice(), j = i + d;
|
||||
if (j < 0 || j >= a.length) return;
|
||||
var t = a[i]; a[i] = a[j]; a[j] = t;
|
||||
this.lists[lst] = a;
|
||||
},
|
||||
|
||||
async save() {
|
||||
this.saving = true;
|
||||
this.saved = false;
|
||||
@@ -87,7 +183,13 @@ function systemSettings() {
|
||||
|
||||
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
||||
var payload = {
|
||||
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : ''
|
||||
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : '',
|
||||
default_measurement_interval_minutes: String(this.defaultInterval || '').trim(),
|
||||
// virgola decimale accettata in input, il backend vuole il punto
|
||||
general_tolerance_plus: String(this.tolPlus || '').trim().replace(',', '.'),
|
||||
general_tolerance_minus: String(this.tolMinus || '').trim().replace(',', '.'),
|
||||
list_measurement_types: this.lists.types.length ? JSON.stringify(this.lists.types) : '',
|
||||
list_units: this.lists.units.length ? JSON.stringify(this.lists.units) : ''
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -111,4 +213,5 @@ function systemSettings() {
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% include "admin/_users_script.html" %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -204,6 +204,17 @@
|
||||
maxlength="255">
|
||||
</div>
|
||||
|
||||
{# Worker visione (#45): vuoto = worker centrale #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Worker visione (URL)') }}</label>
|
||||
<input type="text" x-model="form.vision_worker_url"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="host:8100"
|
||||
maxlength="500">
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
||||
<textarea x-model="form.notes" rows="3"
|
||||
@@ -452,6 +463,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
name: '',
|
||||
location: '',
|
||||
notes: '',
|
||||
vision_worker_url: '', // #45
|
||||
active: true,
|
||||
},
|
||||
|
||||
@@ -505,7 +517,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
this.isEditing = false;
|
||||
this.editingId = null;
|
||||
this.errorMsg = '';
|
||||
this.form = { code: '', name: '', location: '', notes: '', active: true };
|
||||
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', active: true };
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
@@ -518,6 +530,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
name: station.name,
|
||||
location: station.location || '',
|
||||
notes: station.notes || '',
|
||||
vision_worker_url: station.vision_worker_url || '', // #45
|
||||
active: station.active,
|
||||
};
|
||||
this.showModal = true;
|
||||
@@ -537,6 +550,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
name: this.form.name,
|
||||
location: this.form.location || null,
|
||||
notes: this.form.notes || null,
|
||||
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||
active: this.form.active,
|
||||
};
|
||||
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
||||
@@ -563,6 +577,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
name: this.form.name,
|
||||
location: this.form.location || null,
|
||||
notes: this.form.notes || null,
|
||||
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
|
||||
active: this.form.active,
|
||||
};
|
||||
const resp = await fetch('/admin/api/stations', {
|
||||
|
||||
@@ -26,8 +26,50 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
||||
<div x-data="pinLogin()" x-init="init()">
|
||||
<!-- Vista Operatore: nome + PIN (#59) -->
|
||||
<div x-show="mode === 'pin'" x-cloak>
|
||||
<!-- Elenco nomi -->
|
||||
<div x-show="!selected">
|
||||
<p class="text-sm text-center text-[var(--text-secondary)] mb-4">{{ _('Chi sei?') }}</p>
|
||||
<div class="grid grid-cols-2 gap-3" id="pin-user-list">
|
||||
<template x-for="u in users" :key="u.id">
|
||||
<button type="button" @click="select(u)"
|
||||
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tastierino -->
|
||||
<div x-show="selected" id="pin-pad">
|
||||
<p class="text-center font-semibold mb-1" x-text="selected && selected.display_name"></p>
|
||||
<p class="text-sm text-center text-[var(--text-secondary)] mb-3">{{ _('Inserisci il PIN') }}</p>
|
||||
<div class="flex justify-center gap-3 mb-3" aria-hidden="true">
|
||||
<template x-for="i in 4" :key="i">
|
||||
<span class="h-4 w-4 rounded-full border-2 border-[var(--border-color)]"
|
||||
:class="pin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-sm text-center text-red-600 min-h-[1.25rem] mb-2" role="alert" x-text="error"></p>
|
||||
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
|
||||
<template x-for="d in ['1','2','3','4','5','6','7','8','9']" :key="d">
|
||||
<button type="button" @click="press(d)" :disabled="busy"
|
||||
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
|
||||
</template>
|
||||
<button type="button" @click="back()" :disabled="busy"
|
||||
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
|
||||
<button type="button" @click="press('0')" :disabled="busy"
|
||||
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
|
||||
<button type="button" @click="del()" :disabled="busy"
|
||||
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-6 text-center">
|
||||
<a href="#" @click.prevent="mode = 'password'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con password') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form (password: admin e chi non ha PIN) -->
|
||||
<form x-show="mode === 'password'" method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Username -->
|
||||
<div>
|
||||
@@ -98,6 +140,11 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="mt-4 text-center" x-show="mode === 'password' && users.length" x-cloak>
|
||||
<a href="#" @click.prevent="mode = 'pin'" class="text-sm text-primary-600 hover:underline">{{ _('Accedi con PIN') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
@@ -110,8 +157,20 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center text-xs text-[var(--text-muted)]">
|
||||
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
|
||||
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ app_commit }}{% endif %}</p>
|
||||
</div>
|
||||
</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 %}
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<!-- Footer -->
|
||||
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
||||
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
|
||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic
|
||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic{# versione e commit (#70) #} · v{{ app_version }}{% if app_commit %} · {{ app_commit }}{% endif %}
|
||||
</p>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
{% set icon_stations %}<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>{% endset %}
|
||||
{% set icon_settings %}<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>{% endset %}
|
||||
|
||||
{% set icon_monitor %}<path stroke-linecap="round" stroke-linejoin="round" d="M9.348 14.652a3.75 3.75 0 010-5.304m5.304 0a3.75 3.75 0 010 5.304m-7.425 2.121a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>{% endset %}
|
||||
|
||||
{# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
|
||||
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
|
||||
<a href="{{ href }}"
|
||||
@@ -31,8 +33,11 @@
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
|
||||
{% endif %}
|
||||
{# #57: Monitor produzione, visibile ad admin e capoturno (stessa regola di monitor_required) #}
|
||||
{% if current_user.get('is_admin') or (current_user.get('roles') and 'Supervisor' in current_user.roles) %}
|
||||
{{ nav_link(url_for('monitor.index'), _('Monitor'), icon_monitor, ep.startswith('monitor.'), mobile) }}
|
||||
{% endif %}
|
||||
{% if current_user.get('is_admin') %}
|
||||
{{ nav_link(url_for('admin.user_list'), _('Utenti'), icon_users, ep == 'admin.user_list', mobile) }}
|
||||
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }}
|
||||
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
|
||||
{% endif %}
|
||||
|
||||
@@ -503,6 +503,32 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
||||
<script>
|
||||
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
||||
ImageCrop.configure({
|
||||
pdfSrc: '{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}',
|
||||
workerSrc: '{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}',
|
||||
labels: {
|
||||
title: {{ _('Ritaglia immagine')|tojson }},
|
||||
page: {{ _('Pagina')|tojson }},
|
||||
hint: {{ _('Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.')|tojson }},
|
||||
pdfNote: {{ _('Un PDF ritagliato viene salvato come immagine PNG.')|tojson }},
|
||||
confirm: {{ _('Conferma ritaglio')|tojson }},
|
||||
original: {{ _('Usa originale')|tojson }},
|
||||
cancel: {{ _('Annulla')|tojson }},
|
||||
loading: {{ _('Caricamento...')|tojson }},
|
||||
error: {{ _('Impossibile aprire il file per il ritaglio.')|tojson }}
|
||||
}
|
||||
});
|
||||
// #56: apre la modale di ritaglio; null = annullato, altrimenti il file da caricare
|
||||
async function tmfCropBeforeUpload(file) {
|
||||
const res = await ImageCrop.open(file);
|
||||
if (res === null || res === file) return res;
|
||||
const base = (file.name || 'file').replace(/\.[^.]+$/, '');
|
||||
return new File([res], base + '-ritaglio.png', { type: 'image/png' });
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
function recipeEditor() {
|
||||
return {
|
||||
@@ -526,7 +552,8 @@ function recipeEditor() {
|
||||
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
|
||||
|
||||
// ---- Measurement interval ----
|
||||
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
|
||||
// #49: a new recipe starts from the system default duration (default_interval), an existing one never does
|
||||
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else (default_interval if not recipe and default_interval else 'null'))|tojson }},
|
||||
|
||||
// ---- Rules of measurement (points 8 and 9) ----
|
||||
// A new recipe forbids typing: the caliper is the instrument, and permission
|
||||
@@ -672,6 +699,10 @@ function recipeEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
// #56: scelta di ritaglio (immagine/PDF) prima dell'upload; senza ritaglio il file resta invariato
|
||||
file = await tmfCropBeforeUpload(file);
|
||||
if (!file) return;
|
||||
|
||||
this.uploadingFile = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
|
||||
@@ -10,6 +10,51 @@
|
||||
filter: 'all',
|
||||
deleteModal: false,
|
||||
deleteTarget: null,
|
||||
// #52: duplicate modal state
|
||||
dupModal: false,
|
||||
dupSource: null,
|
||||
dupCode: '',
|
||||
dupName: '',
|
||||
dupError: '',
|
||||
dupBusy: false,
|
||||
|
||||
openDupModal(recipe) {
|
||||
this.dupSource = recipe;
|
||||
this.dupCode = recipe.code + '-COPIA';
|
||||
this.dupName = recipe.name + ' ' + {{ _('(copia)')|tojson_attr }};
|
||||
this.dupError = '';
|
||||
this.dupModal = true;
|
||||
},
|
||||
|
||||
async duplicateRecipe() {
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
||||
this.dupBusy = true;
|
||||
this.dupError = '';
|
||||
try {
|
||||
const resp = await fetch(`/maker/api/recipes/${this.dupSource.id}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
|
||||
body: JSON.stringify({ code: this.dupCode.trim(), name: this.dupName.trim() })
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok) {
|
||||
// open the new recipe in the editor
|
||||
window.location.href = '/maker/recipes/' + data.id + '/edit';
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
this.dupError = {{ _('Esiste già una ricetta con questo codice')|tojson_attr }};
|
||||
} else if (resp.status === 422) {
|
||||
this.dupError = {{ _('Codice o nome non validi')|tojson_attr }};
|
||||
} else {
|
||||
this.dupError = data.detail || data.error || {{ _('Errore durante la duplicazione')|tojson_attr }};
|
||||
}
|
||||
} catch (err) {
|
||||
this.dupError = {{ _('Errore di connessione')|tojson_attr }};
|
||||
} finally {
|
||||
this.dupBusy = false;
|
||||
}
|
||||
},
|
||||
|
||||
get filteredRecipes() {
|
||||
let filtered = this.recipes;
|
||||
@@ -265,6 +310,15 @@
|
||||
{{ _('Versioni') }}
|
||||
</a>
|
||||
|
||||
<!-- Duplica (#52) -->
|
||||
<button @click="openDupModal(recipe)"
|
||||
class="btn btn-secondary gap-1.5 text-xs">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{{ _('Duplica') }}
|
||||
</button>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
@@ -318,6 +372,42 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Duplicate Modal (#52) -->
|
||||
<div x-show="dupModal"
|
||||
x-cloak
|
||||
@keydown.escape.window="dupModal = false"
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
<div @click="dupModal = false" class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div class="tmf-card max-w-lg w-full relative p-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
{{ _('Duplica ricetta') }}
|
||||
</h3>
|
||||
<div class="mb-3">
|
||||
<label class="tmf-label" for="dup-code">{{ _('Codice') }}</label>
|
||||
<input id="dup-code" type="text" x-model="dupCode" maxlength="100" class="tmf-input">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="tmf-label" for="dup-name">{{ _('Nome') }}</label>
|
||||
<input id="dup-name" type="text" x-model="dupName" maxlength="255" class="tmf-input">
|
||||
</div>
|
||||
<p x-show="dupError" x-text="dupError" class="text-sm text-red-600 mb-3"></p>
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button @click="dupModal = false" class="btn btn-secondary flex-1 justify-center">
|
||||
{{ _('Annulla') }}
|
||||
</button>
|
||||
<button @click="duplicateRecipe()"
|
||||
:disabled="dupBusy || !dupCode.trim() || !dupName.trim()"
|
||||
class="btn btn-primary flex-1 justify-center">
|
||||
{{ _('Duplica') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div x-show="deleteModal"
|
||||
x-cloak
|
||||
|
||||
@@ -91,6 +91,74 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# #53 #55: parte del form task comune a creazione e modifica; `d` e il nome dello stato Alpine (newTask / editTaskData) #}
|
||||
{% macro task_flags_editor(d) %}
|
||||
<div class="sm:col-span-2 space-y-3">
|
||||
<!-- #53: il task entra nel ciclo di misura (timer e cicalino) -->
|
||||
<label class="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" class="mt-0.5"
|
||||
x-model="{{ d }}.in_loop"
|
||||
@change="{{ d }}.in_loop_touched = true">
|
||||
<span>{{ _('Nel ciclo di misura (timer e cicalino)') }}</span>
|
||||
</label>
|
||||
<!-- #55: opzioni valide solo per il tipo Nota -->
|
||||
<template x-if="{{ d }}.task_type === 'note'">
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" class="mt-0.5" x-model="{{ d }}.at_close">
|
||||
<span>{{ _('Compila a fine produzione') }}</span>
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label class="tmf-label mb-0">{{ _('Campi da compilare') }}</label>
|
||||
<button type="button" class="btn btn-secondary text-xs gap-1"
|
||||
:disabled="{{ d }}.fields.length >= MAX_NOTE_FIELDS"
|
||||
@click="addNoteField({{ d }})">
|
||||
+ {{ _('Aggiungi campo') }}
|
||||
</button>
|
||||
</div>
|
||||
<template x-for="(f, fi) in {{ d }}.fields" :key="f._uid">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-2 p-2 rounded border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||
<div class="sm:col-span-4">
|
||||
<input type="text" class="tmf-input text-sm" maxlength="100"
|
||||
placeholder="{{ _('Etichetta') }}"
|
||||
x-model="f.label"
|
||||
@input="deriveNoteFieldKey({{ d }}, f)">
|
||||
<span class="block mt-0.5 text-[10px] font-mono text-[var(--text-muted)]" x-text="f.key"></span>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<select class="tmf-input text-sm" x-model="f.type">
|
||||
<option value="text">{{ _('Testo') }}</option>
|
||||
<option value="number">{{ _('Numero') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-3">
|
||||
<input type="text" class="tmf-input text-sm"
|
||||
placeholder="{{ _('Valore precompilato') }}"
|
||||
x-model="f.default">
|
||||
</div>
|
||||
<label class="sm:col-span-1 flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input type="checkbox" x-model="f.attachment">
|
||||
<span>{{ _('Allegato') }}</span>
|
||||
</label>
|
||||
<div class="sm:col-span-2 flex items-center justify-end gap-1">
|
||||
<button type="button" class="p-1 rounded hover:bg-[var(--bg-card-hover)]"
|
||||
:disabled="fi === 0" @click="moveNoteField({{ d }}, fi, -1)"
|
||||
title="{{ _('Sposta su') }}">↑</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') }}">↓</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') }}">×</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">
|
||||
{# Fermo linea + Fine produzione: task di misura o del ciclo (#53: anche un check nel loop) #}
|
||||
<template x-if="(isMeasureTask || isMeasurementTaskOfRun) && 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. #}
|
||||
@@ -264,6 +256,88 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# #55: the inputs of the fields of a «note» task. One block for the task on screen
|
||||
and for the panel at the end of production, so the rules are written once. #}
|
||||
{% macro result_fields() %}
|
||||
<template x-for="fld in f.fields" :key="fld.key">
|
||||
<div class="mb-3">
|
||||
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" x-text="fld.label"></label>
|
||||
<input :type="fld.type === 'number' ? 'number' : 'text'"
|
||||
:step="fld.type === 'number' ? 'any' : null"
|
||||
:inputmode="fld.type === 'number' ? 'decimal' : null"
|
||||
x-model="f.values[fld.key]"
|
||||
class="input w-full text-base py-2">
|
||||
<template x-if="fld.attachment">
|
||||
<div class="mt-1.5">
|
||||
<label class="btn btn-secondary text-xs gap-1.5 cursor-pointer">
|
||||
{{ _('Allega immagine o PDF') }}
|
||||
<input type="file" accept="image/*,application/pdf" class="hidden"
|
||||
@change="pickFile(f, fld.key, $event)">
|
||||
</label>
|
||||
<span class="ml-2 text-xs text-[var(--text-muted)]" x-text="f.fileNames[fld.key]"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
{% endmacro %}
|
||||
|
||||
{# #54 #55: result panel of the task on screen - OK / NOK for a «check», the
|
||||
fields and «Completato» for a «note» with fields. #}
|
||||
<div x-show="hasResultPanel" x-cloak
|
||||
x-data="taskResultForm()"
|
||||
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => advanceAfterResult() });
|
||||
initForTask(task);
|
||||
$watch('productionRun && productionRun.id', () => loadLast())"
|
||||
@task-result-reset.window="justRecorded = false; initForTask(task)"
|
||||
class="shrink-0 max-h-[65%] overflow-y-auto px-4 py-3 bg-[var(--bg-card)] border-b border-[var(--border-color)]">
|
||||
<template x-for="f in forms" :key="f.task_id">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div x-show="f.result" class="mb-3 px-3 py-2 rounded-lg text-sm border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||
<span class="text-[var(--text-secondary)]">{{ _('Ultimo esito') }}:</span>
|
||||
<span class="font-bold"
|
||||
:class="f.result && f.result.outcome === 'nok' ? 'text-measure-fail' : 'text-measure-pass'"
|
||||
x-text="resultLabel(f.result)"></span>
|
||||
<span class="text-[var(--text-muted)]" x-text="f.result && f.result.note ? '— ' + f.result.note : ''"></span>
|
||||
</div>
|
||||
|
||||
<template x-if="f.type === 'check'">
|
||||
<div>
|
||||
<textarea x-model="f.note" rows="2"
|
||||
placeholder="{{ _('Nota (obbligatoria per un NOK)') }}"
|
||||
class="input w-full text-base mb-3"></textarea>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button @click="submit('ok')" :disabled="working"
|
||||
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
||||
{{ _('OK') }}
|
||||
</button>
|
||||
<button @click="submit('nok')" :disabled="working"
|
||||
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-red-600 text-white hover:bg-red-700 disabled:opacity-50">
|
||||
{{ _('NOK') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="f.type === 'note'">
|
||||
<div>
|
||||
{{ result_fields() }}
|
||||
<button @click="submit('done')" :disabled="working"
|
||||
class="btn text-lg font-bold w-full min-h-[64px] justify-center gap-2 shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
||||
<span>{{ _('Completato') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p x-show="f.error" class="mt-2 text-sm text-measure-fail" x-text="f.error"></p>
|
||||
{# The result is in, but going on failed (cycle refused, network): another try, without recording twice. #}
|
||||
<button x-show="justRecorded" @click="proceed()"
|
||||
class="btn btn-secondary mt-3 w-full justify-center">
|
||||
{{ _('Prosegui') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# Main image area #}
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
|
||||
@@ -755,11 +829,14 @@
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!isMeasureTask || subtasks.length === 0">
|
||||
{# #54 #55: a check / note-with-fields task has its own buttons in the panel #}
|
||||
<template x-if="(!isMeasureTask || subtasks.length === 0) && !hasResultPanel">
|
||||
{# #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') }}
|
||||
@@ -845,6 +922,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">
|
||||
@@ -897,6 +978,39 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
#55 TASK DI CHIUSURA — before the capoturno's modal, the notes marked «alla fine
|
||||
della produzione» are filled in and recorded on the run. The modal below is
|
||||
untouched: it opens when this panel is done.
|
||||
================================================================ #}
|
||||
<div x-show="open" x-cloak
|
||||
x-data="taskResultForm()"
|
||||
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => closeTasksRecorded() })"
|
||||
@task-close-open.window="openClose($event.detail.ids)"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 w-full max-w-xl mx-4 max-h-[90vh] overflow-y-auto border border-[var(--border-color)]">
|
||||
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-3">{{ _('Prima di chiudere la produzione') }}</h3>
|
||||
<p x-show="working && !forms.length" class="text-sm text-[var(--text-muted)]">{{ _('Caricamento...') }}</p>
|
||||
<template x-for="f in forms" :key="f.task_id">
|
||||
<div class="mb-4" x-show="!f.recorded">
|
||||
<div class="font-semibold text-[var(--text-primary)] mb-1" x-text="f.title"></div>
|
||||
<div class="text-sm text-[var(--text-secondary)] mb-2" x-show="f.directive" x-text="f.directive"></div>
|
||||
{{ result_fields() }}
|
||||
<textarea x-model="f.note" rows="2" placeholder="{{ _('Nota') }}" class="input w-full text-base"></textarea>
|
||||
<p x-show="f.error" class="mt-1 text-sm text-measure-fail" x-text="f.error"></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="error" class="mb-2 text-sm text-measure-fail" x-text="error"></p>
|
||||
<div class="flex gap-3 mt-2">
|
||||
<button @click="cancelClose()" class="btn btn-secondary flex-1 justify-center">{{ _('Annulla') }}</button>
|
||||
<button @click="submitClose()" :disabled="working || !forms.length"
|
||||
class="btn flex-1 justify-center font-bold bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
||||
{{ _('Registra e prosegui') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
SUPERVISOR LOGIN MODAL
|
||||
================================================================ #}
|
||||
@@ -923,7 +1037,55 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-5">
|
||||
{# #59: two ways to authorise - PIN (default, touch pad) or password. #}
|
||||
<div class="flex gap-2 mb-4" role="tablist">
|
||||
<button type="button" role="tab" @click="setSupervisorMode('pin')"
|
||||
:disabled="!supervisorPinUsers.length"
|
||||
class="btn flex-1 text-sm"
|
||||
:class="supervisorMode === 'pin' ? 'btn-primary' : 'btn-secondary'">{{ _('PIN') }}</button>
|
||||
<button type="button" role="tab" @click="setSupervisorMode('password')"
|
||||
class="btn flex-1 text-sm"
|
||||
:class="supervisorMode === 'password' ? 'btn-primary' : 'btn-secondary'">{{ _('Password') }}</button>
|
||||
</div>
|
||||
|
||||
<div x-show="supervisorMode === 'pin'" class="mb-5">
|
||||
{# name grid #}
|
||||
<div x-show="!supervisorPinUser">
|
||||
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Chi sei?') }}</p>
|
||||
<div class="grid grid-cols-2 gap-3 max-h-64 overflow-y-auto">
|
||||
<template x-for="u in supervisorPinUsers" :key="u.id">
|
||||
<button type="button" @click="selectSupervisorUser(u)"
|
||||
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
{# pad #}
|
||||
<div x-show="supervisorPinUser">
|
||||
<p class="text-center font-semibold mb-1" x-text="supervisorPinUser && supervisorPinUser.display_name"></p>
|
||||
<p class="text-xs 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="supervisorPin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
|
||||
</template>
|
||||
</div>
|
||||
<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="pressSupervisorPin(d)" :disabled="supervisorValidating"
|
||||
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
|
||||
</template>
|
||||
<button type="button" @click="backSupervisorUser()" :disabled="supervisorValidating"
|
||||
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
|
||||
<button type="button" @click="pressSupervisorPin('0')" :disabled="supervisorValidating"
|
||||
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
|
||||
<button type="button" @click="supervisorPin = supervisorPin.slice(0, -1)" :disabled="supervisorValidating"
|
||||
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="supervisorError" class="text-sm text-center text-red-600 mt-3" role="alert" x-text="supervisorError"></p>
|
||||
</div>
|
||||
|
||||
<div x-show="supervisorMode === 'password'" class="space-y-3 mb-5">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
||||
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
||||
@@ -947,13 +1109,13 @@
|
||||
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
|
||||
</button>
|
||||
<button @click="submitSupervisor()"
|
||||
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
|
||||
:disabled="!supervisorCanSubmit || supervisorValidating"
|
||||
class="btn btn-primary flex-1 text-sm gap-1">
|
||||
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
{{ _('Autorizza') }}
|
||||
<span x-text="supervisorMode === 'pin' ? '{{ _('Conferma') }}' : '{{ _('Autorizza') }}'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -982,6 +1144,26 @@
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
||||
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
|
||||
<script>
|
||||
window.__taskResultsCfg = {
|
||||
saveUrl: '{{ url_for("measure.api_save_task_result") }}',
|
||||
listUrl: '{{ url_for("measure.api_list_task_results") }}',
|
||||
uploadUrl: '{{ url_for("measure.api_upload_attachment") }}',
|
||||
taskUrlTpl: '{{ url_for("measure.api_get_task", task_id=0) }}',
|
||||
labels: {
|
||||
connection: {{ _('Errore di connessione')|tojson }},
|
||||
sessionExpired: {{ _('Sessione scaduta: ricaricare la pagina')|tojson }},
|
||||
serverError: {{ _('Errore di comunicazione con il server')|tojson }},
|
||||
nokNeedsNote: {{ _('Per un NOK serve la nota')|tojson }},
|
||||
notANumber: {{ _('Valore non numerico')|tojson }},
|
||||
outcomeOk: {{ _('OK')|tojson }},
|
||||
outcomeNok: {{ _('NOK')|tojson }},
|
||||
outcomeDone: {{ _('Registrato')|tojson }},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/task-results.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>
|
||||
@@ -1030,6 +1212,10 @@ function taskExecute() {
|
||||
// The measurement tasks of this recipe, in order: the loop the operator stays
|
||||
// in once production starts, and what says which task closes a cycle.
|
||||
measurementTaskIds: [],
|
||||
// #55: notes to fill in before the capoturno closes the production, and whether
|
||||
// they were already recorded (so cancelling the modal does not ask them again).
|
||||
closeTaskIds: [],
|
||||
closeTasksDone: false,
|
||||
// The interval has elapsed and it has been said out loud - buzzer, and the
|
||||
// operator on their way back to the measurement. Kept so it is said once.
|
||||
dueAnnounced: false,
|
||||
@@ -1041,6 +1227,11 @@ function taskExecute() {
|
||||
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
||||
supervisorUsername: '',
|
||||
supervisorPassword: '',
|
||||
// #59: the capoturno can authorise with name + 4-digit PIN instead of the password.
|
||||
supervisorMode: 'pin', // 'pin' | 'password'
|
||||
supervisorPinUsers: [], // [{id, display_name}] from /measure/api/pin-users
|
||||
supervisorPinUser: null,
|
||||
supervisorPin: '', // never kept past the modal: cleared on open and close
|
||||
supervisorError: '',
|
||||
supervisorValidating: false,
|
||||
/* The out-of-tolerance measurement waiting for the capoturno:
|
||||
@@ -1250,11 +1441,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") }}';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1265,6 +1463,7 @@ function taskExecute() {
|
||||
this.productionStarted = run.status !== 'closed';
|
||||
this.cycleCount = run.cycle_count;
|
||||
this.measurementTaskIds = run.measurement_task_ids || [];
|
||||
this.closeTaskIds = run.close_task_ids || []; // #55
|
||||
|
||||
const seconds = run.seconds_to_next_measurement;
|
||||
if (seconds === null || seconds === undefined) {
|
||||
@@ -1691,6 +1890,7 @@ function taskExecute() {
|
||||
this.currentValue = null;
|
||||
this.errorMessage = '';
|
||||
this.inputStartedAt = Date.now();
|
||||
window.dispatchEvent(new CustomEvent('task-result-reset')); // #54: a check / note loops on the spot
|
||||
},
|
||||
|
||||
// ---- Where the measurement lives ----
|
||||
@@ -1767,13 +1967,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
|
||||
@@ -1787,6 +1983,33 @@ function taskExecute() {
|
||||
return window.ProductionClock.format(this.timerRemaining);
|
||||
},
|
||||
|
||||
/* #53: result recorded on a check / note task. In the loop of a production the
|
||||
task counts as a step of the cycle exactly like a measurement task: the last
|
||||
one closes the cycle (the server decides, as in confirmCycle), the others go
|
||||
to the next one. If the server refuses, the panel stays with «Prosegui». */
|
||||
async advanceAfterResult() {
|
||||
if (this.productionRun && this.isMeasurementTaskOfRun) {
|
||||
const countBefore = this.cycleCount;
|
||||
const run = await this.postProduction(
|
||||
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
||||
{ task_id: this.task.id },
|
||||
);
|
||||
if (!run) return;
|
||||
this.adoptProductionRun(run);
|
||||
if (run.cycle_count === countBefore && this.nextMeasurementTaskId) {
|
||||
this.goToTask(this.nextMeasurementTaskId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.goToNextTask();
|
||||
},
|
||||
|
||||
/* #55: the end-of-production notes are recorded: now the capoturno's modal. */
|
||||
closeTasksRecorded() {
|
||||
this.closeTasksDone = true;
|
||||
this.openSupervisorModal('fine_produzione');
|
||||
},
|
||||
|
||||
// ---- Navigate to next task (Completato) ----
|
||||
/* With a production open the operator stays in the measurement loop until the
|
||||
capoturno closes it: after the last measurement task they go back to the
|
||||
@@ -1821,10 +2044,23 @@ 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;
|
||||
}
|
||||
// #55: first the notes marked «alla fine della produzione»; the panel calls back
|
||||
// here (closeTasksRecorded) when they are recorded.
|
||||
if (action === 'fine_produzione' && this.closeTaskIds.length && !this.closeTasksDone) {
|
||||
window.dispatchEvent(new CustomEvent('task-close-open', { detail: { ids: this.closeTaskIds } }));
|
||||
return;
|
||||
}
|
||||
this.supervisorAction = action;
|
||||
this.supervisorUsername = '';
|
||||
this.supervisorPassword = '';
|
||||
this.supervisorError = '';
|
||||
this.resetSupervisorPin(); // #59: PIN fields cleared, names reloaded
|
||||
this.showSupervisorModal = true;
|
||||
},
|
||||
|
||||
@@ -1833,6 +2069,68 @@ function taskExecute() {
|
||||
this.supervisorUsername = '';
|
||||
this.supervisorPassword = '';
|
||||
this.supervisorError = '';
|
||||
this.supervisorPin = ''; // #59: the PIN does not outlive the modal
|
||||
this.supervisorPinUser = null;
|
||||
},
|
||||
|
||||
/* #59: PIN mode of the capoturno modal. */
|
||||
resetSupervisorPin() {
|
||||
this.supervisorPin = '';
|
||||
this.supervisorPinUser = null;
|
||||
this.supervisorMode = 'pin';
|
||||
this.loadSupervisorPinUsers();
|
||||
},
|
||||
|
||||
// Names with a PIN; an empty list or a failure opens the Password tab instead.
|
||||
async loadSupervisorPinUsers() {
|
||||
try {
|
||||
const resp = await fetch('{{ url_for("measure.api_pin_users") }}', { headers: { 'Accept': 'application/json' } });
|
||||
const list = resp.ok ? await resp.json() : [];
|
||||
this.supervisorPinUsers = Array.isArray(list) ? list : [];
|
||||
} catch (e) {
|
||||
this.supervisorPinUsers = [];
|
||||
}
|
||||
if (!this.supervisorPinUsers.length) this.supervisorMode = 'password';
|
||||
},
|
||||
|
||||
setSupervisorMode(mode) {
|
||||
this.supervisorMode = mode;
|
||||
this.supervisorPin = '';
|
||||
this.supervisorPinUser = null;
|
||||
this.supervisorError = '';
|
||||
},
|
||||
|
||||
selectSupervisorUser(u) {
|
||||
this.supervisorPinUser = u;
|
||||
this.supervisorPin = '';
|
||||
this.supervisorError = '';
|
||||
},
|
||||
|
||||
backSupervisorUser() {
|
||||
this.supervisorPinUser = null;
|
||||
this.supervisorPin = '';
|
||||
this.supervisorError = '';
|
||||
},
|
||||
|
||||
// Submits by itself on the fourth digit.
|
||||
pressSupervisorPin(d) {
|
||||
if (this.supervisorValidating || this.supervisorPin.length >= 4) return;
|
||||
this.supervisorPin += d;
|
||||
this.supervisorError = '';
|
||||
if (this.supervisorPin.length === 4) this.submitSupervisor();
|
||||
},
|
||||
|
||||
get supervisorCanSubmit() {
|
||||
if (this.supervisorMode === 'pin') return !!this.supervisorPinUser && this.supervisorPin.length === 4;
|
||||
return !!this.supervisorUsername && !!this.supervisorPassword;
|
||||
},
|
||||
|
||||
// The pair the proxies forward: id + PIN in PIN mode, username + password otherwise.
|
||||
supervisorCredentials() {
|
||||
if (this.supervisorMode === 'pin') {
|
||||
return { supervisor_user_id: this.supervisorPinUser && this.supervisorPinUser.id, supervisor_pin: this.supervisorPin };
|
||||
}
|
||||
return { supervisor_username: this.supervisorUsername, supervisor_password: this.supervisorPassword };
|
||||
},
|
||||
|
||||
/* What kind of task this is, as declared in the recipe.
|
||||
@@ -1852,6 +2150,19 @@ function taskExecute() {
|
||||
return t === 'camera_measure' || t === 'dxf_compare';
|
||||
},
|
||||
|
||||
/* #54: «Controllo OK/NOK» - no quotes, the result is the outcome. */
|
||||
get isCheckTask() {
|
||||
return this.task.task_type === 'check';
|
||||
},
|
||||
|
||||
/* #54 #55: tasks that bring their own result panel (a check, or a note with
|
||||
fields). A note without fields stays the plain «Completato» task. */
|
||||
get hasResultPanel() {
|
||||
if (this.isCheckTask) return true;
|
||||
return this.task.task_type === 'note' && Array.isArray(this.task.fields)
|
||||
&& this.task.fields.length > 0;
|
||||
},
|
||||
|
||||
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
||||
loud rather than silently rendering an empty screen. */
|
||||
get isMeasureTaskWithoutQuotes() {
|
||||
@@ -1903,16 +2214,15 @@ function taskExecute() {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify({
|
||||
supervisor_username: this.supervisorUsername,
|
||||
supervisor_password: this.supervisorPassword,
|
||||
}),
|
||||
body: JSON.stringify(this.supervisorCredentials()), // #59: PIN or password
|
||||
});
|
||||
const data = await resp.json().catch(() => null);
|
||||
|
||||
if (!resp.ok || (data && data.error)) {
|
||||
this.supervisorError = (data && data.detail)
|
||||
|| (this.supervisorMode === 'pin' && resp.status === 401 ? '{{ _("PIN errato") }}' : '')
|
||||
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
||||
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN stays in the modal, pad cleared
|
||||
this.supervisorValidating = false;
|
||||
return;
|
||||
}
|
||||
@@ -1947,10 +2257,7 @@ function taskExecute() {
|
||||
|
||||
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
|
||||
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
|
||||
const run = await this.postProduction(base, {
|
||||
supervisor_username: this.supervisorUsername,
|
||||
supervisor_password: this.supervisorPassword,
|
||||
});
|
||||
const run = await this.postProduction(base, this.supervisorCredentials()); // #59: PIN or password
|
||||
|
||||
this.supervisorValidating = false;
|
||||
if (!run) {
|
||||
@@ -1958,6 +2265,7 @@ function taskExecute() {
|
||||
// capoturno sees why the action was refused, rather than a silent no-op.
|
||||
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
|
||||
this.productionError = '';
|
||||
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN / lockout: pad cleared, modal stays
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -116,6 +129,74 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #58: esiti, note, campi e allegati dei task della sessione scelta -->
|
||||
<div id="run-task-results" x-show="selectedRunId" x-cloak
|
||||
class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 mb-6 shadow-sm">
|
||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Esiti dei task della sessione") }}</h2>
|
||||
<p x-show="taskResultsLoading" class="text-sm text-steel">{{ _("Caricamento...") }}</p>
|
||||
<p x-show="taskResultsError" x-text="taskResultsError" class="text-sm text-red-600"></p>
|
||||
<template x-if="taskResults && !taskResultsLoading">
|
||||
<div>
|
||||
<div class="grid grid-cols-3 gap-3 mb-4 max-w-md">
|
||||
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
|
||||
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="taskResults.counts.ok"></div>
|
||||
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("OK") }}</div>
|
||||
</div>
|
||||
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
|
||||
<div class="text-2xl font-bold font-mono text-red-600" x-text="taskResults.counts.nok"></div>
|
||||
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("NOK") }}</div>
|
||||
</div>
|
||||
<div class="text-center p-3 rounded-lg bg-[var(--bg-primary)]">
|
||||
<div class="text-2xl font-bold font-mono text-[var(--text-primary)]" x-text="taskResults.counts.done"></div>
|
||||
<div class="text-xs text-steel mt-1">{{ _("Fatto") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="taskResults.results.length === 0" class="text-sm text-steel">{{ _("Nessun esito registrato in questa sessione") }}</p>
|
||||
<div x-show="taskResults.results.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-steel uppercase border-b border-[var(--border-color)]">
|
||||
<th class="py-2 pr-3">{{ _("Ora") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Task") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Esito") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Note") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Campi") }}</th>
|
||||
<th class="py-2 pr-3">{{ _("Allegati") }}</th>
|
||||
<th class="py-2">{{ _("Operatore") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="r in taskResults.results" :key="r.id">
|
||||
<tr class="border-b border-[var(--border-color)] align-top">
|
||||
<td class="py-2 pr-3 font-mono whitespace-nowrap" x-text="resultTime(r.created_at)"></td>
|
||||
<td class="py-2 pr-3" x-text="r.task_title"></td>
|
||||
<td class="py-2 pr-3">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-semibold"
|
||||
:class="r.outcome === 'nok' ? 'bg-red-600 text-white' : (r.outcome === 'ok' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-700')"
|
||||
x-text="outcomeLabel(r.outcome)"></span>
|
||||
</td>
|
||||
<td class="py-2 pr-3 whitespace-pre-line" x-text="r.note"></td>
|
||||
<td class="py-2 pr-3">
|
||||
<template x-for="v in r.values" :key="v.key">
|
||||
<div><span class="text-steel" x-text="v.label + ': '"></span><span x-text="v.value"></span></div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-2 pr-3">
|
||||
<template x-for="a in r.attachments" :key="a.path">
|
||||
<div><a :href="attachmentUrl(a.path)" target="_blank" rel="noopener"
|
||||
class="text-primary hover:underline" x-text="a.name || a.label"></a></div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="py-2" x-text="r.user_name"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Nessuna ricetta selezionata -->
|
||||
<template x-if="!selectedRecipeId && !loading">
|
||||
<div class="text-center py-16 text-steel">
|
||||
@@ -258,6 +339,8 @@ function spcDashboard() {
|
||||
// Filter state
|
||||
selectedRecipeId: '',
|
||||
selectedSubtaskId: '',
|
||||
selectedRunId: '', // #58
|
||||
runs: [], // #58 sessioni della ricetta
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
subtasks: [],
|
||||
@@ -275,12 +358,58 @@ function spcDashboard() {
|
||||
reportError: '',
|
||||
downloadingReport: false,
|
||||
|
||||
// #58: esiti dei task della sessione scelta
|
||||
taskResults: null,
|
||||
taskResultsLoading: false,
|
||||
taskResultsError: '',
|
||||
|
||||
init() {
|
||||
// Nothing to pre-load
|
||||
// #58: il pannello esiti si ricarica a ogni cambio di sessione
|
||||
this.$watch('selectedRunId', () => this.loadTaskResults());
|
||||
},
|
||||
|
||||
async loadTaskResults() {
|
||||
this.taskResults = null;
|
||||
this.taskResultsError = '';
|
||||
const runId = this.selectedRunId;
|
||||
if (!runId) return;
|
||||
this.taskResultsLoading = true;
|
||||
try {
|
||||
const resp = await fetch('/statistics/api/runs/' + runId + '/task-results');
|
||||
const data = await resp.json();
|
||||
// #58: se nel frattempo la sessione e' cambiata, il risultato e' vecchio
|
||||
if (runId !== this.selectedRunId) return;
|
||||
if (!resp.ok || data.error) {
|
||||
this.taskResultsError = data.detail || {{ _("Errore nel caricamento dei dati")|tojson }};
|
||||
} else {
|
||||
this.taskResults = data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading task results:', e);
|
||||
this.taskResultsError = {{ _("Errore di connessione al server")|tojson }};
|
||||
} finally {
|
||||
if (runId === this.selectedRunId) this.taskResultsLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// #58: ora locale dell'esito (created_at e' UTC senza suffisso)
|
||||
resultTime(iso) {
|
||||
return iso ? new Date(iso + (iso.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
|
||||
},
|
||||
|
||||
outcomeLabel(o) {
|
||||
return o === 'ok' ? {{ _("OK")|tojson }} : (o === 'nok' ? {{ _("NOK")|tojson }} : {{ _("Fatto")|tojson }});
|
||||
},
|
||||
|
||||
// #58: gli allegati passano dal proxy del blueprint, il browser non manda X-API-Key
|
||||
attachmentUrl(path) {
|
||||
return '/statistics/api/files/' + String(path).split('/').map(encodeURIComponent).join('/');
|
||||
},
|
||||
|
||||
async onRecipeChange() {
|
||||
this.selectedSubtaskId = '';
|
||||
this.selectedRunId = ''; // #58
|
||||
this.runs = [];
|
||||
this.subtasks = [];
|
||||
this.hasData = false;
|
||||
this.summary = null;
|
||||
@@ -290,6 +419,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 +440,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 +456,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 +562,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,110 @@
|
||||
// Behaviour test for static/js/task-results.js (#53 #54 #55), plain node, no framework.
|
||||
// Run: node tests/js/test_task_results.js (exit code != 0 on failure)
|
||||
const assert = require('assert');
|
||||
const { taskResultForm } = require('../../static/js/task-results.js');
|
||||
|
||||
const calls = [];
|
||||
let responses = [];
|
||||
global.window = {
|
||||
__taskResultsCfg: {
|
||||
saveUrl: '/s', listUrl: '/l', uploadUrl: '/u', taskUrlTpl: '/t/0',
|
||||
labels: { nokNeedsNote: 'Per un NOK serve la nota' },
|
||||
},
|
||||
sessionStorage: { _d: {}, getItem(k) { return this._d[k] || null; }, setItem(k, v) { this._d[k] = v; } },
|
||||
};
|
||||
global.FormData = class { constructor() { this.d = []; } append(k, v, n) { this.d.push([k, v, n]); } };
|
||||
global.fetch = async (url, opts) => {
|
||||
calls.push({ url, opts });
|
||||
const r = responses.shift() || { status: 200, body: {} };
|
||||
return { ok: r.status < 400, status: r.status, json: async () => r.body };
|
||||
};
|
||||
|
||||
function comp(runId) {
|
||||
const c = taskResultForm();
|
||||
c.ctx = () => ({ runId, done() { c._done = (c._done || 0) + 1; } });
|
||||
return c;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// check: NOK without note is refused before any request
|
||||
let c = comp(4);
|
||||
c.initForTask({ id: 30, task_type: 'check' });
|
||||
responses = [{ status: 200, body: [] }]; // loadLast
|
||||
await c.submit('nok');
|
||||
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
|
||||
assert.ok(!calls.some((x) => x.url === '/s'), 'no POST for a NOK without note');
|
||||
|
||||
// check: OK goes to the server with the run id, then hands over
|
||||
calls.length = 0;
|
||||
responses = [{ status: 201, body: { id: 1, outcome: 'ok' } }];
|
||||
await c.submit('ok');
|
||||
const post = calls.find((x) => x.url === '/s');
|
||||
const body = JSON.parse(post.opts.body);
|
||||
assert.deepStrictEqual([body.task_id, body.production_run_id, body.outcome, body.values], [30, 4, 'ok', null]);
|
||||
assert.strictEqual(c._done, 1);
|
||||
assert.strictEqual(c.forms[0].result.outcome, 'ok');
|
||||
|
||||
// server 422: the detail is shown, nothing is handed over
|
||||
c = comp(4);
|
||||
responses = [{ status: 200, body: [] }, { status: 422, body: { detail: 'Per un NOK serve la nota' } }];
|
||||
c.initForTask({ id: 30, task_type: 'check' }); // consumes the first one (loadLast)
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
c.forms[0].note = 'x';
|
||||
await c.submit('nok');
|
||||
assert.strictEqual(c.forms[0].error, 'Per un NOK serve la nota');
|
||||
assert.ok(!c._done);
|
||||
|
||||
// note with fields: defaults, upload of the attachment, values + attachments in the result
|
||||
calls.length = 0;
|
||||
c = comp(null);
|
||||
const task = { id: 31, task_type: 'note', fields: [
|
||||
{ key: 'peso', label: 'Peso', type: 'number', default: '1,5', attachment: false },
|
||||
{ key: 'foto', label: 'Foto', type: 'text', default: null, attachment: true },
|
||||
] };
|
||||
c.initForTask(task);
|
||||
assert.strictEqual(c.forms[0].values.peso, '1,5');
|
||||
c.forms[0].files.foto = { name: 'a.png' };
|
||||
responses = [
|
||||
{ status: 201, body: { file_path: 'general/a.png' } },
|
||||
{ status: 201, body: { id: 2, outcome: 'done' } },
|
||||
];
|
||||
await c.submit('done');
|
||||
const saved = JSON.parse(calls.find((x) => x.url === '/s').opts.body);
|
||||
assert.deepStrictEqual(saved.values, { peso: '1.5' });
|
||||
assert.deepStrictEqual(saved.attachments, [{ key: 'foto', path: 'general/a.png', name: 'a.png' }]);
|
||||
assert.strictEqual(saved.outcome, 'done');
|
||||
assert.strictEqual(saved.production_run_id, null);
|
||||
|
||||
// non numeric value in a number field is refused locally
|
||||
c = comp(null);
|
||||
c.initForTask(task);
|
||||
c.forms[0].values.peso = 'abc';
|
||||
await c.submit('done');
|
||||
assert.ok(c.forms[0].error.indexOf('Peso') !== -1);
|
||||
|
||||
// a note without fields has no panel
|
||||
c = comp(null);
|
||||
c.initForTask({ id: 33, task_type: 'note', fields: [] });
|
||||
assert.strictEqual(c.forms.length, 0);
|
||||
|
||||
// end of production: tasks already recorded in the run are skipped; the rest recorded then handed over
|
||||
calls.length = 0;
|
||||
c = comp(4);
|
||||
responses = [
|
||||
{ status: 200, body: [{ task_id: 40 }] }, // results of the run
|
||||
{ status: 200, body: { id: 40, title: 'A', task_type: 'note', fields: [] } },
|
||||
{ status: 200, body: { id: 41, title: 'B', task_type: 'note', fields: [{ key: 'k', label: 'K', type: 'text', default: null, attachment: false }] } },
|
||||
];
|
||||
await c.openClose([40, 41]);
|
||||
assert.strictEqual(c.forms[0].recorded, true);
|
||||
assert.strictEqual(c.forms[1].recorded, false);
|
||||
assert.ok(c.open && !c._done);
|
||||
c.forms[1].values.k = 'ok';
|
||||
responses = [{ status: 201, body: { id: 5, outcome: 'done' } }];
|
||||
await c.submitClose();
|
||||
assert.strictEqual(c._done, 1);
|
||||
assert.strictEqual(c.open, false);
|
||||
assert.strictEqual(calls.filter((x) => x.url === '/s').length, 1, 'only the pending task is posted');
|
||||
|
||||
console.log('ok');
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -176,3 +176,42 @@ def test_station_list_page_renders_assigned_recipes(logged_in_client, mock_admin
|
||||
resp = logged_in_client.get("/admin/stations")
|
||||
assert resp.status_code == 200
|
||||
assert b"REC-ABC" in resp.data
|
||||
|
||||
|
||||
# --- Worker visione per stazione (#45) ---
|
||||
|
||||
def test_station_form_has_the_vision_worker_field(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.return_value = []
|
||||
resp = logged_in_client.get("/admin/stations")
|
||||
assert resp.status_code == 200
|
||||
assert b"form.vision_worker_url" in resp.data
|
||||
# etichetta in italiano o in inglese, secondo il catalogo compilato
|
||||
assert b"Worker visione (URL)" in resp.data or b"Vision worker (URL)" in resp.data
|
||||
|
||||
|
||||
def test_station_list_exposes_the_vision_worker_url(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.get.side_effect = [
|
||||
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
|
||||
"notes": None, "active": True, "vision_worker_url": "http://10.0.0.7:8100",
|
||||
"created_by": 1, "created_at": "2026-04-25T10:00:00"}],
|
||||
[],
|
||||
]
|
||||
resp = logged_in_client.get("/admin/stations")
|
||||
assert b"http://10.0.0.7:8100" in resp.data # nel JSON iniziale della pagina
|
||||
|
||||
|
||||
def test_station_proxies_pass_the_vision_worker_url_untouched(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.post.return_value = {"id": 7, "code": "ST-7", "name": "n"}
|
||||
mock_admin_api.put.return_value = {"id": 7, "code": "ST-7", "name": "n"}
|
||||
logged_in_client.post(
|
||||
"/admin/api/stations",
|
||||
json={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
|
||||
)
|
||||
mock_admin_api.post.assert_called_once_with(
|
||||
"/api/stations",
|
||||
data={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
|
||||
)
|
||||
logged_in_client.put("/admin/api/stations/7", json={"vision_worker_url": None})
|
||||
mock_admin_api.put.assert_called_once_with(
|
||||
"/api/stations/7", data={"vision_worker_url": None},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""#59/#51/#57: PIN nella gestione utenti (proxy admin, pannello) e link Monitor nel menu."""
|
||||
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, "has_pin": 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_pin_proxy_forwards_four_digits(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.put.return_value = {"message": "ok"}
|
||||
resp = logged_in_client.put("/admin/api/users/2/pin", json={"pin": "1234"})
|
||||
assert resp.status_code == 200
|
||||
mock_admin_api.put.assert_called_once_with("/api/users/2/pin", data={"pin": "1234"})
|
||||
|
||||
|
||||
def test_pin_proxy_forwards_null_to_remove(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.put.return_value = {"message": "ok"}
|
||||
resp = logged_in_client.put("/admin/api/users/2/pin", json={"pin": None})
|
||||
assert resp.status_code == 200
|
||||
mock_admin_api.put.assert_called_once_with("/api/users/2/pin", data={"pin": None})
|
||||
|
||||
|
||||
def test_pin_proxy_passes_422_detail(logged_in_client, mock_admin_api):
|
||||
mock_admin_api.put.return_value = {
|
||||
"error": True, "status_code": 422, "detail": "Gli admin non hanno il PIN",
|
||||
}
|
||||
resp = logged_in_client.put("/admin/api/users/1/pin", json={"pin": "1234"})
|
||||
assert resp.status_code == 422
|
||||
assert resp.get_json()["detail"] == "Gli admin non hanno il PIN"
|
||||
|
||||
|
||||
def test_pin_proxy_requires_admin(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"}
|
||||
resp = client.put("/admin/api/users/2/pin", json={"pin": "1234"})
|
||||
assert resp.status_code != 200
|
||||
mock_admin_api.put.assert_not_called()
|
||||
|
||||
|
||||
def test_users_panel_has_pin_controls(logged_in_client, mock_admin_api):
|
||||
html = logged_in_client.get("/admin/settings?tab=utenti").data.decode("utf-8")
|
||||
assert "openPin(user)" in html # pulsante Imposta/Reset PIN
|
||||
assert "user.has_pin === true" in html # indicatore PIN impostato
|
||||
assert "removePin()" in html # Togli PIN
|
||||
assert "form.pin" in html # campo PIN alla creazione
|
||||
assert "!user.is_admin" in html # niente PIN per gli admin
|
||||
|
||||
|
||||
def _session_client(client, roles, is_admin):
|
||||
with client.session_transaction() as sess:
|
||||
sess["api_key"] = "k"
|
||||
sess["user"] = {"id": 5, "username": "u", "display_name": "U", "roles": roles,
|
||||
"is_admin": is_admin, "language_pref": "it", "theme_pref": "light"}
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_monitor_api():
|
||||
mock = MagicMock()
|
||||
mock.get.return_value = []
|
||||
with patch("blueprints.monitor.api_client", mock), \
|
||||
patch("blueprints.measure.api_client", mock):
|
||||
yield mock
|
||||
|
||||
|
||||
def test_monitor_link_for_admin(logged_in_client, mock_monitor_api):
|
||||
html = logged_in_client.get("/monitor").data.decode("utf-8")
|
||||
assert 'href="/monitor"' in html
|
||||
|
||||
|
||||
def test_monitor_link_for_supervisor(client, mock_monitor_api):
|
||||
_session_client(client, ["Supervisor"], False)
|
||||
html = client.get("/monitor").data.decode("utf-8")
|
||||
assert 'href="/monitor"' in html
|
||||
|
||||
|
||||
def test_monitor_link_absent_for_operator(client, mock_api_client, monkeypatch):
|
||||
# Stessa preparazione di test_measure.py: la pagina vuole STATION_CODE a config ricaricata
|
||||
monkeypatch.setenv("STATION_CODE", "ST-TEST")
|
||||
import importlib
|
||||
import config
|
||||
importlib.reload(config)
|
||||
import blueprints.measure
|
||||
importlib.reload(blueprints.measure)
|
||||
mock_api_client.get_station_recipes.return_value = []
|
||||
_session_client(client, ["MeasurementTec"], False)
|
||||
resp = client.get("/measure/select")
|
||||
assert resp.status_code == 200
|
||||
html = resp.data.decode("utf-8")
|
||||
assert "/measure/select" in html or "Misure" in html or "Measure" in html # menu reso
|
||||
assert 'href="/monitor"' not in html
|
||||
@@ -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"})
|
||||
@@ -136,3 +136,67 @@ class TestLanguageSwitch:
|
||||
with client.session_transaction() as sess:
|
||||
# Invalid language should NOT be stored
|
||||
assert sess.get("language") != "xx"
|
||||
|
||||
|
||||
class TestPinLogin:
|
||||
"""Login Operatore con nome + PIN (#59)."""
|
||||
|
||||
USER = {
|
||||
"id": 7, "username": "mario", "display_name": "Mario Rossi",
|
||||
"roles": ["MeasurementTec"], "is_admin": False,
|
||||
"language_pref": "it", "theme_pref": "light", "active": True,
|
||||
}
|
||||
|
||||
def test_login_page_has_pin_view_and_password_link(self, client):
|
||||
resp = client.get("/auth/login")
|
||||
assert b"pin-pad.js" in resp.data
|
||||
assert b"pin-pad" in resp.data # tastierino presente nel markup
|
||||
assert b'name="password"' in resp.data # form password ancora presente
|
||||
|
||||
def test_pin_users_listed(self, client, mock_api_client):
|
||||
mock_api_client.get.return_value = [
|
||||
{"id": 7, "display_name": "Mario Rossi", "secret": "x"},
|
||||
]
|
||||
resp = client.get("/auth/pin-users")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == [{"id": 7, "display_name": "Mario Rossi"}]
|
||||
|
||||
def test_pin_users_empty_on_error(self, client, mock_api_client):
|
||||
mock_api_client.get.return_value = {"error": True, "detail": "boom"}
|
||||
assert client.get("/auth/pin-users").get_json() == []
|
||||
|
||||
def test_pin_users_empty_list_falls_back(self, client, mock_api_client):
|
||||
mock_api_client.get.return_value = []
|
||||
assert client.get("/auth/pin-users").get_json() == []
|
||||
|
||||
def test_login_pin_success_sets_session(self, client, mock_api_client):
|
||||
mock_api_client.post.return_value = {"api_key": "pin-key", "user": self.USER}
|
||||
mock_api_client.get.return_value = {}
|
||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "1234"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["ok"] is True
|
||||
assert resp.get_json()["redirect"]
|
||||
mock_api_client.post.assert_called_once_with(
|
||||
"/api/auth/login-pin", data={"user_id": 7, "pin": "1234"})
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["api_key"] == "pin-key"
|
||||
assert sess["user_id"] == 7
|
||||
assert sess.permanent is True
|
||||
|
||||
def test_login_pin_wrong_pin_401(self, client, mock_api_client):
|
||||
mock_api_client.post.return_value = {"error": True, "status_code": 401, "detail": "x"}
|
||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
||||
assert resp.status_code == 401
|
||||
assert resp.get_json()["detail"] == "PIN errato"
|
||||
|
||||
def test_login_pin_locked_423_shows_detail(self, client, mock_api_client):
|
||||
mock_api_client.post.return_value = {
|
||||
"error": True, "status_code": 423, "detail": "Utente bloccato fino alle 10:15"}
|
||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
||||
assert resp.status_code == 423
|
||||
assert "10:15" in resp.get_json()["detail"]
|
||||
|
||||
def test_login_pin_invalid_pin_422(self, client, mock_api_client):
|
||||
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "12"})
|
||||
assert resp.status_code == 422
|
||||
mock_api_client.post.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Versione e commit nel piede di pagina (#70)."""
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from config import Config
|
||||
|
||||
_PYPROJECT = Path(__file__).resolve().parents[4] / "pyproject.toml"
|
||||
|
||||
|
||||
def _pyproject_version() -> str:
|
||||
with _PYPROJECT.open("rb") as fh:
|
||||
return tomllib.load(fh)["project"]["version"]
|
||||
|
||||
|
||||
def test_app_version_matches_pyproject():
|
||||
# La versione mostrata e' quella di pyproject.toml, non un valore a mano
|
||||
assert Config.APP_VERSION == _pyproject_version()
|
||||
|
||||
|
||||
def test_login_page_shows_version(client):
|
||||
resp = client.get("/auth/login")
|
||||
assert resp.status_code == 200
|
||||
assert f"v{_pyproject_version()}" in resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_login_page_shows_commit_when_set(client, monkeypatch):
|
||||
# Commit valorizzato (gia' troncato a 7 da Config): compare nel footer
|
||||
monkeypatch.setattr(Config, "APP_COMMIT", "abc1234")
|
||||
assert "abc1234" in client.get("/auth/login").get_data(as_text=True)
|
||||
|
||||
|
||||
def test_login_page_hides_commit_when_empty(client, monkeypatch):
|
||||
# Build senza APP_COMMIT: solo versione, nessun separatore orfano
|
||||
monkeypatch.setattr(Config, "APP_COMMIT", "")
|
||||
html = client.get("/auth/login").get_data(as_text=True)
|
||||
assert f"v{_pyproject_version()}" in html
|
||||
assert f"v{_pyproject_version()} ·" not in html
|
||||
@@ -32,6 +32,10 @@ def _views():
|
||||
rel = path.relative_to(TEMPLATES).as_posix()
|
||||
if rel.startswith("components/") or rel == "base.html":
|
||||
continue
|
||||
# #65: i parziali (nome che comincia per "_") sono inclusi da un'altra vista
|
||||
# e stanno dentro la sua cornice .tmf-page: da soli non ne hanno una.
|
||||
if path.name.startswith("_"):
|
||||
continue
|
||||
yield rel, path
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user