Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc94b2e778 | |||
| c8dd5eecfd | |||
| f7d6d008c7 | |||
| 837f3d1069 | |||
| f5a2affa0f | |||
| 7ef28b7a9f | |||
| 06b87a15b3 | |||
| 3b3ecceb2a | |||
| 3899d5ecd9 | |||
| f986c172c2 | |||
| e44ac3129f | |||
| 4e52095423 | |||
| 23bfc93f1c | |||
| c1c7ae8d0e | |||
| cf49695140 | |||
| c9e01a223b | |||
| 3690c35c47 | |||
| fc1902a1bb | |||
| 6648882ec1 | |||
| b5aa19170e | |||
| 2d03577165 | |||
| 27dc5e77af | |||
| 8e3316957c | |||
| 3c1d5f1323 | |||
| db5d686938 | |||
| c764cf73c6 | |||
| 86290274e5 | |||
| ee0f65ed39 | |||
| 18d0db8a0c | |||
| 3856ee335a | |||
| e640c4a36e | |||
| accf416cc9 | |||
| 84a4b5f34d | |||
| 9238f8926b | |||
| a829bf3eb0 | |||
| 7ac291b8ac | |||
| 486e1288bf | |||
| 0d004c43ff | |||
| 35f46926a9 |
@@ -92,6 +92,8 @@ SESSION_COOKIE_SECURE=true
|
|||||||
VISION_RUN_TIMEOUT_S=100
|
VISION_RUN_TIMEOUT_S=100
|
||||||
# Quante esecuzioni di visione in parallelo accetta il worker.
|
# Quante esecuzioni di visione in parallelo accetta il worker.
|
||||||
VISION_MAX_CONCURRENCY=2
|
VISION_MAX_CONCURRENCY=2
|
||||||
|
# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo).
|
||||||
|
VISION_RECIPES_ROOT=/app/uploads
|
||||||
|
|
||||||
# --- Docker ---
|
# --- Docker ---
|
||||||
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
|
# 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`.
|
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:
|
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
|
```bash
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ services:
|
|||||||
--authentication-policy=mysql_native_password
|
--authentication-policy=mysql_native_password
|
||||||
--character-set-server=utf8mb4
|
--character-set-server=utf8mb4
|
||||||
--collation-server=utf8mb4_unicode_ci
|
--collation-server=utf8mb4_unicode_ci
|
||||||
|
--default-time-zone=+00:00
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ services:
|
|||||||
--authentication-policy=mysql_native_password
|
--authentication-policy=mysql_native_password
|
||||||
--character-set-server=utf8mb4
|
--character-set-server=utf8mb4
|
||||||
--collation-server=utf8mb4_unicode_ci
|
--collation-server=utf8mb4_unicode_ci
|
||||||
|
--default-time-zone=+00:00
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -70,6 +71,9 @@ services:
|
|||||||
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
|
||||||
container_name: tmflow-vision
|
container_name: tmflow-vision
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# #46: radice delle cartelle ricetta (volume uploads, sola lettura)
|
||||||
|
VISION_RECIPES_ROOT: /app/uploads
|
||||||
volumes:
|
volumes:
|
||||||
- upload_data:/app/uploads:ro
|
- upload_data:/app/uploads:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ vision = [
|
|||||||
"vs-task; python_version>='3.13'",
|
"vs-task; python_version>='3.13'",
|
||||||
"vs-measure; python_version>='3.13'",
|
"vs-measure; python_version>='3.13'",
|
||||||
"vs-pm2d; 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'",
|
"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-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
|
||||||
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
|
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
|
||||||
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", 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]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
"""API Key authentication dependency for FastAPI."""
|
"""API Key authentication dependency for FastAPI."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services.auth_service import hash_api_key
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
|
|
||||||
|
_RENEW_INTERVAL = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
@@ -24,15 +32,25 @@ async def get_current_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).where(User.api_key == api_key, User.active == True)
|
select(ApiKey, User)
|
||||||
|
.join(User, User.id == ApiKey.user_id)
|
||||||
|
.where(ApiKey.key_hash == hash_api_key(api_key), User.active == True)
|
||||||
)
|
)
|
||||||
user = result.scalar_one_or_none()
|
row = result.first()
|
||||||
|
now = utcnow()
|
||||||
|
|
||||||
if user is None:
|
if row is None or row[0].expires_at <= now:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid or inactive API key",
|
detail="Invalid or expired API key",
|
||||||
)
|
)
|
||||||
|
key_row, user = row
|
||||||
|
|
||||||
|
# Sliding TTL, written at most once per _RENEW_INTERVAL to spare the DB.
|
||||||
|
if key_row.last_used_at is None or now - key_row.last_used_at > _RENEW_INTERVAL:
|
||||||
|
key_row.last_used_at = now
|
||||||
|
key_row.expires_at = now + timedelta(hours=settings.api_key_ttl_hours)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
# Store user_id in request state for access logging middleware
|
# Store user_id in request state for access logging middleware
|
||||||
request.state.user_id = user.id
|
request.state.user_id = user.id
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Authentication router - login, logout, profile."""
|
"""Authentication router - login, logout, profile."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
@@ -34,15 +34,16 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def logout(
|
async def logout(
|
||||||
|
request: Request,
|
||||||
all_sessions: bool = False,
|
all_sessions: bool = False,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Logout. The API key is shared across stations logged in as the same
|
"""Logout. Deletes this session's API key only; ?all_sessions=true
|
||||||
user, so by default this does not invalidate it - only this station's
|
deletes every key of the user."""
|
||||||
session (its Flask session, on the client side) ends. Pass
|
await logout_user(
|
||||||
?all_sessions=true to invalidate the key everywhere."""
|
db, user, key=request.headers.get("X-API-Key"), all_sessions=all_sessions
|
||||||
await logout_user(db, user, all_sessions=all_sessions)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.models.api.tipi import UtcNaiveInput
|
||||||
|
from src.backend.utils.tempo import as_utc_naive, iso_utc, to_local, utcnow
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@@ -232,8 +234,8 @@ async def get_measurements(
|
|||||||
measured_by: int | None = Query(None),
|
measured_by: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||||
production_run_id: int | None = Query(None, gt=0),
|
production_run_id: int | None = Query(None, gt=0),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -290,8 +292,8 @@ async def export_measurements_csv(
|
|||||||
measured_by: int | None = Query(None),
|
measured_by: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -372,7 +374,7 @@ async def export_measurements_csv(
|
|||||||
neutralise_csv_cell(m.lot_number or ""),
|
neutralise_csv_cell(m.lot_number or ""),
|
||||||
neutralise_csv_cell(m.serial_number or ""),
|
neutralise_csv_cell(m.serial_number or ""),
|
||||||
m.input_method,
|
m.input_method,
|
||||||
m.measured_at.isoformat(),
|
iso_utc(m.measured_at),
|
||||||
m.synced_to_csv,
|
m.synced_to_csv,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -382,6 +384,6 @@ async def export_measurements_csv(
|
|||||||
iter([output.getvalue()]),
|
iter([output.getvalue()]),
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
"Content-Disposition": f"attachment; filename=measurements_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ from src.backend.models.api.production import (
|
|||||||
)
|
)
|
||||||
from src.backend.models.orm.production import ProductionRun
|
from src.backend.models.orm.production import ProductionRun
|
||||||
from src.backend.models.orm.user import User
|
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"])
|
router = APIRouter(prefix="/api/production-runs", tags=["production"])
|
||||||
|
|
||||||
@@ -81,6 +82,27 @@ async def get_current_production_run(
|
|||||||
return await _as_response(db, run) if run is not None else None
|
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)
|
@router.get("/{run_id}", response_model=ProductionRunWithEventsResponse)
|
||||||
async def get_production_run(
|
async def get_production_run(
|
||||||
run_id: int,
|
run_id: int,
|
||||||
|
|||||||
@@ -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.orm.user import User
|
||||||
from src.backend.models.api.recipe import (
|
from src.backend.models.api.recipe import (
|
||||||
RecipeCreate,
|
RecipeCreate,
|
||||||
|
RecipeDuplicate,
|
||||||
RecipeListResponse,
|
RecipeListResponse,
|
||||||
RecipeResponse,
|
RecipeResponse,
|
||||||
RecipeUpdate,
|
RecipeUpdate,
|
||||||
@@ -52,6 +53,18 @@ async def create_recipe(
|
|||||||
return _recipe_to_response(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)
|
@router.get("", response_model=RecipeListResponse)
|
||||||
async def list_recipes(
|
async def list_recipes(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Reports router — PDF report generation endpoints for Metrologist."""
|
"""Reports router — PDF report generation endpoints for Metrologist."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcNaiveInput
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
@@ -18,11 +18,12 @@ async def download_spc_report(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -32,6 +33,7 @@ async def download_spc_report(
|
|||||||
db, recipe_id, subtask_id,
|
db, recipe_id, subtask_id,
|
||||||
version_id, date_from, date_to,
|
version_id, date_from, date_to,
|
||||||
operator_id, lot_number, serial_number,
|
operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -52,11 +54,12 @@ async def download_measurement_report(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int | None = Query(None),
|
subtask_id: int | None = Query(None),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -66,6 +69,7 @@ async def download_measurement_report(
|
|||||||
db, recipe_id, subtask_id,
|
db, recipe_id, subtask_id,
|
||||||
version_id, date_from, date_to,
|
version_id, date_from, date_to,
|
||||||
operator_id, lot_number, serial_number,
|
operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
"""System settings router - read/update settings, upload company logo."""
|
"""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 fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -18,6 +21,23 @@ _VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
|
|||||||
_VALID_CSV_DECIMAL_SEPARATORS = {",", "."}
|
_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:
|
def _validate_setting_value(key: str, value: str) -> None:
|
||||||
"""Raise 422 for a value that would break the code reading this key.
|
"""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,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail="auto_logout_minutes deve essere vuoto (disabilitato) o un intero fra 1 e 480",
|
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"):
|
elif key in ("company_name", "company_logo_path"):
|
||||||
if not value.strip() or len(value) > 200:
|
if not value.strip() or len(value) > 200:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ NOT protected by API Key auth (standalone access).
|
|||||||
"""
|
"""
|
||||||
import hmac
|
import hmac
|
||||||
import random
|
import random
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.utils.tempo import iso_utc, utcnow
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
@@ -25,7 +25,7 @@ from src.backend.models.orm import (
|
|||||||
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
|
||||||
)
|
)
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from src.backend.services.auth_service import hash_password
|
from src.backend.services.auth_service import hash_password, issue_api_key, revoke_all_keys
|
||||||
from src.backend.services.measurement_service import calculate_pass_fail
|
from src.backend.services.measurement_service import calculate_pass_fail
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||||
@@ -321,8 +321,6 @@ async def create_admin(body: CreateAdminBody):
|
|||||||
"""Create an admin user with all roles."""
|
"""Create an admin user with all roles."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
|
||||||
api_key = secrets.token_hex(32)
|
|
||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
user = User(
|
user = User(
|
||||||
@@ -332,9 +330,10 @@ async def create_admin(body: CreateAdminBody):
|
|||||||
display_name=body.admin_display_name,
|
display_name=body.admin_display_name,
|
||||||
roles=["Maker", "MeasurementTec", "Metrologist"],
|
roles=["Maker", "MeasurementTec", "Metrologist"],
|
||||||
is_admin=True,
|
is_admin=True,
|
||||||
api_key=api_key,
|
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
api_key = await issue_api_key(session, user)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -404,7 +403,6 @@ async def seed_demo_data(body: PasswordBody):
|
|||||||
email=u["email"],
|
email=u["email"],
|
||||||
roles=u["roles"],
|
roles=u["roles"],
|
||||||
is_admin=u["is_admin"],
|
is_admin=u["is_admin"],
|
||||||
api_key=secrets.token_hex(32),
|
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
created_users.append(user)
|
created_users.append(user)
|
||||||
@@ -558,7 +556,7 @@ async def seed_demo_data(body: PasswordBody):
|
|||||||
tec_user = created_users[2] # tec1
|
tec_user = created_users[2] # tec1
|
||||||
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
|
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
|
||||||
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
|
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
|
||||||
base_date = datetime.now()
|
base_date = utcnow()
|
||||||
|
|
||||||
all_subtasks = subtasks_1 + subtasks_2
|
all_subtasks = subtasks_1 + subtasks_2
|
||||||
measurements_created = 0
|
measurements_created = 0
|
||||||
@@ -606,8 +604,8 @@ async def list_users(body: PasswordBody):
|
|||||||
"roles": u.roles or [],
|
"roles": u.roles or [],
|
||||||
"is_admin": u.is_admin,
|
"is_admin": u.is_admin,
|
||||||
"active": u.active,
|
"active": u.active,
|
||||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
"created_at": iso_utc(u.created_at) or None,
|
||||||
"last_login": u.last_login.isoformat() if u.last_login else None,
|
"last_login": iso_utc(u.last_login) or None,
|
||||||
}
|
}
|
||||||
for u in users
|
for u in users
|
||||||
],
|
],
|
||||||
@@ -652,7 +650,6 @@ async def manage_user(body: ManageUserBody):
|
|||||||
email=body.email,
|
email=body.email,
|
||||||
roles=body.roles,
|
roles=body.roles,
|
||||||
is_admin=body.is_admin,
|
is_admin=body.is_admin,
|
||||||
api_key=secrets.token_hex(32),
|
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -707,7 +704,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
|
|||||||
detail=f"User ID {body.user_id} not found",
|
detail=f"User ID {body.user_id} not found",
|
||||||
)
|
)
|
||||||
user.password_hash = hash_password(body.new_password)
|
user.password_hash = hash_password(body.new_password)
|
||||||
user.api_key = None # Invalidate sessions
|
await revoke_all_keys(session, user.id) # Invalidate sessions
|
||||||
|
|
||||||
return {"status": "ok", "message": f"Password changed for '{user.username}'"}
|
return {"status": "ok", "message": f"Password changed for '{user.username}'"}
|
||||||
|
|
||||||
@@ -730,7 +727,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
|
|||||||
)
|
)
|
||||||
user.active = not user.active
|
user.active = not user.active
|
||||||
if not user.active:
|
if not user.active:
|
||||||
user.api_key = None # Invalidate sessions on deactivation
|
await revoke_all_keys(session, user.id) # on deactivation
|
||||||
new_status = "active" if user.active else "inactive"
|
new_status = "active" if user.active else "inactive"
|
||||||
|
|
||||||
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
|
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"""Statistics router - SPC endpoints for Metrologist dashboard."""
|
"""Statistics router - SPC endpoints for Metrologist dashboard."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.models.api.tipi import UtcNaiveInput
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.api.middleware.api_key import require_metrologist
|
from src.backend.api.middleware.api_key import require_metrologist
|
||||||
from src.backend.models.orm.measurement import Measurement
|
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.recipe import RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
@@ -37,6 +39,7 @@ async def _query_measurements(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None, # #58: solo le misure di una sessione
|
||||||
) -> list[Measurement]:
|
) -> list[Measurement]:
|
||||||
"""Query measurements with common filters.
|
"""Query measurements with common filters.
|
||||||
|
|
||||||
@@ -66,6 +69,9 @@ async def _query_measurements(
|
|||||||
if serial_number is not None:
|
if serial_number is not None:
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
|
||||||
|
if production_run_id is not None:
|
||||||
|
filters.append(Measurement.production_run_id == production_run_id)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Measurement)
|
select(Measurement)
|
||||||
.where(and_(*filters) if filters else True)
|
.where(and_(*filters) if filters else True)
|
||||||
@@ -100,11 +106,12 @@ async def get_summary(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
subtask_id: int | None = Query(None),
|
subtask_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> SummaryData:
|
) -> SummaryData:
|
||||||
@@ -112,6 +119,7 @@ async def get_summary(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
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]
|
pass_fail_values = [m.pass_fail for m in measurements]
|
||||||
return compute_summary(pass_fail_values)
|
return compute_summary(pass_fail_values)
|
||||||
@@ -122,11 +130,12 @@ async def get_capability(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> CapabilityData:
|
) -> CapabilityData:
|
||||||
@@ -134,6 +143,7 @@ async def get_capability(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
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]
|
values = [float(m.value) for m in measurements]
|
||||||
tol = await _get_subtask_tolerances(db, subtask_id)
|
tol = await _get_subtask_tolerances(db, subtask_id)
|
||||||
@@ -145,11 +155,12 @@ async def get_control_chart(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_number: str | None = Query(None),
|
serial_number: str | None = Query(None),
|
||||||
|
production_run_id: int | None = Query(None), # #58
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ControlChartData:
|
) -> ControlChartData:
|
||||||
@@ -157,6 +168,7 @@ async def get_control_chart(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
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]
|
values = [float(m.value) for m in measurements]
|
||||||
timestamps = [m.measured_at for m in measurements]
|
timestamps = [m.measured_at for m in measurements]
|
||||||
@@ -172,11 +184,12 @@ async def get_histogram(
|
|||||||
recipe_id: int = Query(...),
|
recipe_id: int = Query(...),
|
||||||
subtask_id: int = Query(...),
|
subtask_id: int = Query(...),
|
||||||
version_id: int | None = Query(None),
|
version_id: int | None = Query(None),
|
||||||
date_from: datetime | None = Query(None),
|
date_from: UtcNaiveInput = Query(None),
|
||||||
date_to: datetime | None = Query(None),
|
date_to: UtcNaiveInput = Query(None),
|
||||||
operator_id: int | None = Query(None),
|
operator_id: int | None = Query(None),
|
||||||
lot_number: str | None = Query(None),
|
lot_number: str | None = Query(None),
|
||||||
serial_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),
|
n_bins: int = Query(20, ge=5, le=100),
|
||||||
user: User = Depends(require_metrologist),
|
user: User = Depends(require_metrologist),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -185,6 +198,7 @@ async def get_histogram(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
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]
|
values = [float(m.value) for m in measurements]
|
||||||
return compute_histogram(values, n_bins)
|
return compute_histogram(values, n_bins)
|
||||||
@@ -234,3 +248,49 @@ async def get_recipe_subtasks(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return subtasks_list
|
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
|
||||||
|
]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from src.backend.database import get_db
|
|||||||
from src.backend.api.middleware.api_key import require_admin_user
|
from src.backend.api.middleware.api_key import require_admin_user
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||||
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key
|
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ async def change_user_password(
|
|||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
user.password_hash = hash_password(data.password)
|
user.password_hash = hash_password(data.password)
|
||||||
user.api_key = None
|
await revoke_all_keys(db, user.id)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"message": f"Password changed for user {user.username}"}
|
return {"message": f"Password changed for user {user.username}"}
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ async def deactivate_user(
|
|||||||
detail="Cannot deactivate yourself",
|
detail="Cannot deactivate yourself",
|
||||||
)
|
)
|
||||||
user.active = False
|
user.active = False
|
||||||
user.api_key = None
|
await revoke_all_keys(db, user.id)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
@@ -183,5 +183,5 @@ async def regenerate_user_key(
|
|||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
new_key = await regenerate_api_key(db, user_id)
|
new_key = await regenerate_api_key(db, user)
|
||||||
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
|
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
|
||||||
|
|||||||
@@ -5,19 +5,25 @@ the numbers: that is what lets a station's configuration change without touching
|
|||||||
the frontend or the recipes.
|
the frontend or the recipes.
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
|
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.config import settings
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
from src.backend.models.api.vision import (
|
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.task import RecipeTask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.services import vision_service
|
from src.backend.services import vision_service
|
||||||
@@ -126,7 +132,7 @@ async def execute(
|
|||||||
) -> VisionExecuteResponse:
|
) -> VisionExecuteResponse:
|
||||||
task = await _task_or_404(db, task_id)
|
task = await _task_or_404(db, task_id)
|
||||||
|
|
||||||
result, saved = await vision_service.execute_task(
|
result, saved, report = await vision_service.execute_task(
|
||||||
db,
|
db,
|
||||||
task,
|
task,
|
||||||
await _read_image(image),
|
await _read_image(image),
|
||||||
@@ -135,6 +141,7 @@ async def execute(
|
|||||||
lot_number=lot_number,
|
lot_number=lot_number,
|
||||||
serial_number=serial_number,
|
serial_number=serial_number,
|
||||||
production_run_id=production_run_id,
|
production_run_id=production_run_id,
|
||||||
|
image_ext=_IMAGE_EXTENSIONS.get(image.content_type, "png"),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -147,6 +154,111 @@ async def execute(
|
|||||||
)
|
)
|
||||||
for m in saved
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
|
|||||||
db_user: str = "tmflow"
|
db_user: str = "tmflow"
|
||||||
db_password: str = _DEFAULT_DB_PASSWORD
|
db_password: str = _DEFAULT_DB_PASSWORD
|
||||||
|
|
||||||
|
# Presentation time zone (DB is UTC, #42)
|
||||||
|
display_timezone: str = "Europe/Rome"
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
server_host: str = "0.0.0.0"
|
server_host: str = "0.0.0.0"
|
||||||
server_port: int = 8000
|
server_port: int = 8000
|
||||||
@@ -38,6 +41,9 @@ class Settings(BaseSettings):
|
|||||||
# configured limits to keep the cluster-wide effective limit close to what
|
# configured limits to keep the cluster-wide effective limit close to what
|
||||||
# was configured instead of multiplying it by N.
|
# was configured instead of multiplying it by N.
|
||||||
web_concurrency: int = 1
|
web_concurrency: int = 1
|
||||||
|
|
||||||
|
# API key session lifetime (hours); renewed on use (env API_KEY_TTL_HOURS).
|
||||||
|
api_key_ttl_hours: int = 24
|
||||||
# How many reverse proxies between the client and this API are trusted to
|
# How many reverse proxies between the client and this API are trusted to
|
||||||
# have appended their own hop to X-Forwarded-For (Nginx, in the default
|
# have appended their own hop to X-Forwarded-For (Nginx, in the default
|
||||||
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
|
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""API keys as SHA-256 hashes, one per session (#41)
|
||||||
|
|
||||||
|
Replaces the plaintext `users.api_key` column with an `api_keys` table: one
|
||||||
|
row per login, holding only the SHA-256 hex digest, an expiry and the last
|
||||||
|
use. Existing keys are invalidated on purpose, not migrated - the plaintext
|
||||||
|
is exactly what must stop existing, so everybody logs in once again.
|
||||||
|
|
||||||
|
Revision ID: 015_api_key_hash
|
||||||
|
Revises: 014_vision_fk_ondelete
|
||||||
|
Create Date: 2026-10-06
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '015_api_key_hash'
|
||||||
|
down_revision: Union[str, None] = '014_vision_fk_ondelete'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'api_keys',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('key_hash', sa.String(64), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
|
||||||
|
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
|
||||||
|
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
batch.drop_index('ix_users_api_key')
|
||||||
|
batch.drop_constraint('uq_users_api_key', type_='unique')
|
||||||
|
batch.drop_column('api_key')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('users') as batch:
|
||||||
|
batch.add_column(sa.Column('api_key', sa.String(64), nullable=True))
|
||||||
|
batch.create_unique_constraint('uq_users_api_key', ['api_key'])
|
||||||
|
batch.create_index('ix_users_api_key', ['api_key'])
|
||||||
|
|
||||||
|
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
|
||||||
|
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
|
||||||
|
op.drop_table('api_keys')
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
|
||||||
|
|
||||||
|
Percorso relativo sotto upload_path della cartella immutabile che contiene
|
||||||
|
recipe.json e i file di meta (DXF, modelli .npz, ...).
|
||||||
|
|
||||||
|
Revision ID: 016_vision_recipe_dir
|
||||||
|
Revises: 015_api_key_hash
|
||||||
|
Create Date: 2026-10-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '016_vision_recipe_dir'
|
||||||
|
down_revision: Union[str, None] = '015_api_key_hash'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('recipe_tasks') as batch:
|
||||||
|
batch.drop_column('vision_recipe_dir')
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for Measurement operations."""
|
"""Pydantic schemas for Measurement operations."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -54,8 +54,8 @@ class MeasurementResponse(BaseModel):
|
|||||||
# Who let this value stand, when it was out of tolerance. Null on a value in
|
# Who let this value stand, when it was out of tolerance. Null on a value in
|
||||||
# tolerance - and on one that is not and is still waiting for an answer.
|
# tolerance - and on one that is not and is still waiting for an answer.
|
||||||
supervisor_id: Optional[int] = None
|
supervisor_id: Optional[int] = None
|
||||||
authorised_at: Optional[datetime] = None
|
authorised_at: Optional[UtcDateTime] = None
|
||||||
measured_at: datetime
|
measured_at: UtcDateTime
|
||||||
synced_to_csv: bool
|
synced_to_csv: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ class CycleMeasurementResponse(BaseModel):
|
|||||||
subtask_id: int
|
subtask_id: int
|
||||||
value: float
|
value: float
|
||||||
pass_fail: str
|
pass_fail: str
|
||||||
measured_at: datetime
|
measured_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
class CurrentCycleResponse(BaseModel):
|
class CurrentCycleResponse(BaseModel):
|
||||||
@@ -114,8 +114,8 @@ class MeasurementQuery(BaseModel):
|
|||||||
measured_by: Optional[int] = None
|
measured_by: Optional[int] = None
|
||||||
lot_number: Optional[str] = None
|
lot_number: Optional[str] = None
|
||||||
serial_number: Optional[str] = None
|
serial_number: Optional[str] = None
|
||||||
date_from: Optional[datetime] = None
|
date_from: UtcNaiveInput = None
|
||||||
date_to: Optional[datetime] = None
|
date_to: UtcNaiveInput = None
|
||||||
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
|
||||||
page: int = Field(1, ge=1)
|
page: int = Field(1, ge=1)
|
||||||
per_page: int = Field(50, ge=1, le=500)
|
per_page: int = Field(50, ge=1, le=500)
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""API models for the live station monitor (#57)."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorSubtask(BaseModel):
|
||||||
|
"""Mean of the recent measurements of one quote, against its tolerance."""
|
||||||
|
|
||||||
|
subtask_id: int
|
||||||
|
description: str
|
||||||
|
unit: str
|
||||||
|
count: int
|
||||||
|
mean: float
|
||||||
|
ltl: Optional[float] = None
|
||||||
|
utl: Optional[float] = None
|
||||||
|
# True when ltl <= mean <= utl (a missing limit does not constrain).
|
||||||
|
in_tolerance: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorEvent(BaseModel):
|
||||||
|
event_type: str
|
||||||
|
created_at: datetime
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorRun(BaseModel):
|
||||||
|
id: int
|
||||||
|
station_code: str
|
||||||
|
station_name: str
|
||||||
|
recipe_code: str
|
||||||
|
recipe_name: str
|
||||||
|
version_number: int
|
||||||
|
operator: str
|
||||||
|
status: str
|
||||||
|
started_at: datetime
|
||||||
|
duration_seconds: int
|
||||||
|
cycle_count: int
|
||||||
|
seconds_to_next_measurement: Optional[int] = None
|
||||||
|
overdue: bool
|
||||||
|
last_event: Optional[MonitorEvent] = None
|
||||||
|
# Counts over the last MONITOR_WINDOW measurements of the run.
|
||||||
|
measured: int
|
||||||
|
pass_count: int
|
||||||
|
warning_count: int
|
||||||
|
fail_count: int
|
||||||
|
subtasks: list[MonitorSubtask]
|
||||||
|
server_time: datetime
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for production runs and their events."""
|
"""Pydantic schemas for production runs and their events."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel):
|
|||||||
supervisor_id: Optional[int]
|
supervisor_id: Optional[int]
|
||||||
task_id: Optional[int] = None
|
task_id: Optional[int] = None
|
||||||
note: Optional[str]
|
note: Optional[str]
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
class ProductionRunResponse(BaseModel):
|
class ProductionRunResponse(BaseModel):
|
||||||
@@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel):
|
|||||||
serial_number: Optional[str]
|
serial_number: Optional[str]
|
||||||
status: str
|
status: str
|
||||||
measurement_interval_minutes: Optional[int]
|
measurement_interval_minutes: Optional[int]
|
||||||
next_measurement_at: Optional[datetime]
|
next_measurement_at: Optional[UtcDateTime]
|
||||||
cycle_count: int
|
cycle_count: int
|
||||||
started_at: datetime
|
started_at: UtcDateTime
|
||||||
paused_at: Optional[datetime]
|
paused_at: Optional[UtcDateTime]
|
||||||
closed_at: Optional[datetime]
|
closed_at: Optional[UtcDateTime]
|
||||||
closed_by: Optional[int]
|
closed_by: Optional[int]
|
||||||
statistics_path: Optional[str] = None
|
statistics_path: Optional[str] = None
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ class ProductionRunResponse(BaseModel):
|
|||||||
# overdue is a fact the operator must see, not deduce.
|
# overdue is a fact the operator must see, not deduce.
|
||||||
seconds_to_next_measurement: Optional[int] = None
|
seconds_to_next_measurement: Optional[int] = None
|
||||||
overdue: bool = False
|
overdue: bool = False
|
||||||
server_time: Optional[datetime] = None
|
server_time: Optional[UtcDateTime] = None
|
||||||
|
|
||||||
# The measurement tasks of this run's version, in execution order. The client
|
# The measurement tasks of this run's version, in execution order. The client
|
||||||
# needs them to bring the operator back to the measurement when the interval
|
# needs them to bring the operator back to the measurement when the interval
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for Recipe and RecipeVersion operations."""
|
"""Pydantic schemas for Recipe and RecipeVersion operations."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Optional, TYPE_CHECKING
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -26,6 +26,12 @@ class RecipeCreate(BaseModel):
|
|||||||
annotations_json: Optional[dict] = None
|
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):
|
class RecipeUpdate(BaseModel):
|
||||||
"""Schema for updating a recipe (creates new version)."""
|
"""Schema for updating a recipe (creates new version)."""
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
@@ -51,7 +57,7 @@ class RecipeVersionResponse(BaseModel):
|
|||||||
version_number: int
|
version_number: int
|
||||||
is_current: bool
|
is_current: bool
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
change_notes: Optional[str] = None
|
change_notes: Optional[str] = None
|
||||||
tasks: list["TaskResponse"] = []
|
tasks: list["TaskResponse"] = []
|
||||||
|
|
||||||
@@ -72,7 +78,7 @@ class RecipeResponse(BaseModel):
|
|||||||
requires_serial: bool = False
|
requires_serial: bool = False
|
||||||
allow_manual_input: bool = False
|
allow_manual_input: bool = False
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
active: bool
|
active: bool
|
||||||
current_version: Optional[RecipeVersionResponse] = None
|
current_version: Optional[RecipeVersionResponse] = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
"""Pydantic schemas for Station and StationRecipeAssignment."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -29,7 +29,7 @@ class StationResponse(BaseModel):
|
|||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
active: bool
|
active: bool
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
class StationRecipeAssignmentCreate(BaseModel):
|
class StationRecipeAssignmentCreate(BaseModel):
|
||||||
@@ -42,7 +42,7 @@ class StationRecipeAssignmentResponse(BaseModel):
|
|||||||
station_id: int
|
station_id: int
|
||||||
recipe_id: int
|
recipe_id: int
|
||||||
assigned_by: int
|
assigned_by: int
|
||||||
assigned_at: datetime
|
assigned_at: UtcDateTime
|
||||||
|
|
||||||
|
|
||||||
class RecipeSummary(BaseModel):
|
class RecipeSummary(BaseModel):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for SPC statistics responses."""
|
"""Pydantic schemas for SPC statistics responses."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
|
|||||||
recipe_id: int
|
recipe_id: int
|
||||||
version_id: Optional[int] = None
|
version_id: Optional[int] = None
|
||||||
subtask_id: Optional[int] = None
|
subtask_id: Optional[int] = None
|
||||||
date_from: Optional[datetime] = None
|
date_from: UtcNaiveInput = None
|
||||||
date_to: Optional[datetime] = None
|
date_to: UtcNaiveInput = None
|
||||||
operator_id: Optional[int] = None
|
operator_id: Optional[int] = None
|
||||||
lot_number: Optional[str] = None
|
lot_number: Optional[str] = None
|
||||||
serial_number: Optional[str] = None
|
serial_number: Optional[str] = None
|
||||||
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
|
|||||||
class ControlChartData(BaseModel):
|
class ControlChartData(BaseModel):
|
||||||
"""Data for X-bar/R control chart."""
|
"""Data for X-bar/R control chart."""
|
||||||
values: list[float]
|
values: list[float]
|
||||||
timestamps: list[datetime]
|
timestamps: list[UtcDateTime]
|
||||||
mean: float
|
mean: float
|
||||||
ucl: float # Upper Control Limit
|
ucl: float # Upper Control Limit
|
||||||
lcl: float # Lower Control Limit
|
lcl: float # Lower Control Limit
|
||||||
@@ -60,7 +60,7 @@ class CapabilityData(BaseModel):
|
|||||||
|
|
||||||
class TrendData(BaseModel):
|
class TrendData(BaseModel):
|
||||||
"""Temporal trend data for capability indices."""
|
"""Temporal trend data for capability indices."""
|
||||||
dates: list[datetime]
|
dates: list[UtcDateTime]
|
||||||
cpk_values: list[Optional[float]]
|
cpk_values: list[Optional[float]]
|
||||||
ppk_values: list[Optional[float]]
|
ppk_values: list[Optional[float]]
|
||||||
n_per_period: list[int]
|
n_per_period: list[int]
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Shared Pydantic types for datetimes (#42): UTC in the DB, explicit +00:00 on the wire."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
from pydantic import AfterValidator, PlainSerializer
|
||||||
|
|
||||||
|
from src.backend.utils.tempo import as_utc_naive, to_utc_aware
|
||||||
|
|
||||||
|
UtcDateTime = Annotated[
|
||||||
|
datetime, PlainSerializer(lambda v: to_utc_aware(v).isoformat(), return_type=str)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Input: aware values are converted to UTC naive, naive is taken as UTC.
|
||||||
|
UtcNaiveInput = Annotated[Optional[datetime], AfterValidator(as_utc_naive)]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for User operations."""
|
"""Pydantic schemas for User operations."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -48,8 +48,8 @@ class UserResponse(BaseModel):
|
|||||||
language_pref: str
|
language_pref: str
|
||||||
theme_pref: str
|
theme_pref: str
|
||||||
active: bool
|
active: bool
|
||||||
created_at: datetime
|
created_at: UtcDateTime
|
||||||
last_login: Optional[datetime] = None
|
last_login: Optional[UtcDateTime] = None
|
||||||
|
|
||||||
|
|
||||||
class UserPasswordChange(BaseModel):
|
class UserPasswordChange(BaseModel):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Pydantic schemas for the vision execution response."""
|
"""Pydantic schemas for the vision execution response."""
|
||||||
from datetime import datetime
|
from src.backend.models.api.tipi import UtcDateTime
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
@@ -16,6 +16,34 @@ class VisionExecuteResponse(BaseModel):
|
|||||||
vision_result_id: int
|
vision_result_id: int
|
||||||
engine_version: str
|
engine_version: str
|
||||||
measurements: list[VisionMeasurementResult]
|
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):
|
class VisionReferenceImageResponse(BaseModel):
|
||||||
@@ -28,4 +56,4 @@ class VisionReferenceImageResponse(BaseModel):
|
|||||||
note: str | None = None
|
note: str | None = None
|
||||||
station_id: int | None = None
|
station_id: int | None = None
|
||||||
device_code: str | None = None
|
device_code: str | None = None
|
||||||
acquired_at: datetime
|
acquired_at: UtcDateTime
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""SQLAlchemy models for TieMeasureFlow."""
|
"""SQLAlchemy models for TieMeasureFlow."""
|
||||||
from src.backend.models.orm.user import User
|
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.recipe import Recipe, RecipeVersion
|
||||||
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
|
||||||
from src.backend.models.orm.measurement import Measurement
|
from src.backend.models.orm.measurement import Measurement
|
||||||
@@ -11,6 +12,7 @@ from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
|
"ApiKey",
|
||||||
"Recipe",
|
"Recipe",
|
||||||
"RecipeVersion",
|
"RecipeVersion",
|
||||||
"RecipeTask",
|
"RecipeTask",
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""API key sessions: one row per login, stored as a SHA-256 hash."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.backend.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(Base):
|
||||||
|
"""One API key per login session.
|
||||||
|
|
||||||
|
Only the SHA-256 hex digest is stored, never the key. Dates are naive UTC.
|
||||||
|
"""
|
||||||
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
@@ -18,7 +18,8 @@ if TYPE_CHECKING:
|
|||||||
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
|
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
|
||||||
|
|
||||||
# Types the operator screen treats as "there are quotes to take here".
|
# 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")
|
||||||
|
|
||||||
|
|
||||||
class RecipeTask(Base):
|
class RecipeTask(Base):
|
||||||
@@ -51,6 +52,9 @@ class RecipeTask(Base):
|
|||||||
# server routes it and never interprets it, which is what keeps VisionSuite
|
# server routes it and never interprets it, which is what keeps VisionSuite
|
||||||
# out of this container.
|
# out of this container.
|
||||||
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
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)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class User(Base):
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
api_key: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
|
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"""Authentication service - password hashing, API key management."""
|
"""Authentication service - password hashing, API key management."""
|
||||||
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import delete, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
|
||||||
@@ -58,8 +62,8 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
|||||||
of them because it is the same question every time - who is this, and are they
|
of them because it is the same question every time - who is this, and are they
|
||||||
allowed to say yes.
|
allowed to say yes.
|
||||||
|
|
||||||
Uses authenticate_user rather than a full login: logging in would rotate the
|
Uses authenticate_user rather than a full login: logging in would issue a
|
||||||
supervisor's API key and knock out whatever session they have open elsewhere.
|
needless API key for the supervisor.
|
||||||
"""
|
"""
|
||||||
supervisor = await authenticate_user(db, username, password)
|
supervisor = await authenticate_user(db, username, password)
|
||||||
if supervisor is None:
|
if supervisor is None:
|
||||||
@@ -75,39 +79,67 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
|
|||||||
return supervisor
|
return supervisor
|
||||||
|
|
||||||
|
|
||||||
async def login_user(db: AsyncSession, user: User) -> str:
|
def hash_api_key(key: str) -> str:
|
||||||
"""Generate (or reuse) the API key and update last_login for user.
|
"""SHA-256 hex digest of an API key; this is all the DB ever stores."""
|
||||||
|
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
Reuses an existing key rather than rotating it on every login: the same
|
|
||||||
account logged in from two stations at once (a common shift-account
|
async def issue_api_key(db: AsyncSession, user: User) -> str:
|
||||||
pattern here) must keep both sessions working. Rotating the key would
|
"""Create a new session key for the user and return it in clear (once)."""
|
||||||
silently log the other station out.
|
key = generate_api_key()
|
||||||
"""
|
now = utcnow()
|
||||||
api_key = user.api_key or generate_api_key()
|
# Housekeeping: drop this user's expired keys so the table does not grow forever.
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(User)
|
delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
|
||||||
.where(User.id == user.id)
|
)
|
||||||
.values(api_key=api_key, last_login=datetime.utcnow())
|
db.add(
|
||||||
|
ApiKey(
|
||||||
|
user_id=user.id,
|
||||||
|
key_hash=hash_api_key(key),
|
||||||
|
created_at=now,
|
||||||
|
expires_at=now + timedelta(hours=settings.api_key_ttl_hours),
|
||||||
|
last_used_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_all_keys(db: AsyncSession, user_id: int) -> None:
|
||||||
|
"""Invalidate every session key of a user."""
|
||||||
|
await db.execute(delete(ApiKey).where(ApiKey.user_id == user_id))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
async def login_user(db: AsyncSession, user: User) -> str:
|
||||||
|
"""Issue a new API key for this login and update last_login.
|
||||||
|
|
||||||
|
One key per login: the same account used from several stations at once
|
||||||
|
(#25) keeps every station working, because no login revokes another's key.
|
||||||
|
"""
|
||||||
|
api_key = await issue_api_key(db, user)
|
||||||
|
await db.execute(
|
||||||
|
update(User).where(User.id == user.id).values(last_login=utcnow())
|
||||||
)
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return api_key
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
async def logout_user(
|
async def logout_user(
|
||||||
db: AsyncSession, user: User, all_sessions: bool = False
|
db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Log out. By default a no-op on the shared API key.
|
"""Log out. Deletes only the key of this session; all_sessions=True
|
||||||
|
deletes every key of the user."""
|
||||||
Clearing the key on every logout would kick out any other station still
|
if all_sessions:
|
||||||
using the same shift account. `all_sessions=True` is the explicit
|
await revoke_all_keys(db, user.id)
|
||||||
override for a supervisor who wants every session logged out.
|
|
||||||
"""
|
|
||||||
if not all_sessions:
|
|
||||||
return
|
return
|
||||||
await db.execute(
|
if key:
|
||||||
update(User).where(User.id == user.id).values(api_key=None)
|
await db.execute(
|
||||||
)
|
delete(ApiKey).where(
|
||||||
await db.flush()
|
ApiKey.user_id == user.id, ApiKey.key_hash == hash_api_key(key)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
async def create_user(
|
async def create_user(
|
||||||
@@ -138,11 +170,7 @@ async def create_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
|
async def regenerate_api_key(db: AsyncSession, user: User) -> str:
|
||||||
"""Regenerate API key for a user."""
|
"""Revoke every key of the user and issue a fresh one."""
|
||||||
new_key = generate_api_key()
|
await revoke_all_keys(db, user.id)
|
||||||
await db.execute(
|
return await issue_api_key(db, user)
|
||||||
update(User).where(User.id == user_id).values(api_key=new_key)
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
return new_key
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Measurement service - pass/fail calculation, data storage."""
|
"""Measurement service - pass/fail calculation, data storage."""
|
||||||
import math
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -343,7 +344,7 @@ async def authorise_measurement(
|
|||||||
) -> Measurement:
|
) -> Measurement:
|
||||||
"""Let an out-of-tolerance value stand, on the supervisor's name."""
|
"""Let an out-of-tolerance value stand, on the supervisor's name."""
|
||||||
measurement.supervisor_id = supervisor.id
|
measurement.supervisor_id = supervisor.id
|
||||||
measurement.authorised_at = datetime.now()
|
measurement.authorised_at = utcnow()
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(measurement)
|
await db.refresh(measurement)
|
||||||
return measurement
|
return measurement
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Live monitor of the open production runs (#57).
|
||||||
|
|
||||||
|
One query per table, whatever the number of runs: measurements and last events are
|
||||||
|
ranked per run with a window function and cut in the database, not in a loop.
|
||||||
|
"""
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.api.monitor import MonitorEvent, MonitorRun, MonitorSubtask
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.production import ProductionEvent, ProductionRun
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import production_service
|
||||||
|
|
||||||
|
# Recent measurements per run that feed counts and means (#57: last N).
|
||||||
|
MONITOR_WINDOW = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _in_tolerance(mean: float, ltl, utl) -> bool:
|
||||||
|
# A missing limit does not constrain.
|
||||||
|
if ltl is not None and mean < float(ltl):
|
||||||
|
return False
|
||||||
|
if utl is not None and mean > float(utl):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def list_open_runs(db: AsyncSession) -> list[MonitorRun]:
|
||||||
|
runs = (await db.execute(
|
||||||
|
select(ProductionRun)
|
||||||
|
.where(ProductionRun.status.in_(("running", "paused")))
|
||||||
|
.order_by(ProductionRun.started_at)
|
||||||
|
)).scalars().all()
|
||||||
|
if not runs:
|
||||||
|
return []
|
||||||
|
run_ids = [r.id for r in runs]
|
||||||
|
|
||||||
|
# Operators: one query.
|
||||||
|
users = {
|
||||||
|
u.id: u for u in (await db.execute(
|
||||||
|
select(User).where(User.id.in_({r.operator_id for r in runs}))
|
||||||
|
)).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Last event per run: rank desc inside the run, keep rank 1.
|
||||||
|
ev_rank = func.row_number().over(
|
||||||
|
partition_by=ProductionEvent.run_id,
|
||||||
|
order_by=(ProductionEvent.created_at.desc(), ProductionEvent.id.desc()),
|
||||||
|
).label("rn")
|
||||||
|
ev_sub = select(
|
||||||
|
ProductionEvent.run_id.label("run_id"),
|
||||||
|
ProductionEvent.event_type.label("event_type"),
|
||||||
|
ProductionEvent.created_at.label("created_at"),
|
||||||
|
ProductionEvent.note.label("note"),
|
||||||
|
ev_rank,
|
||||||
|
).where(ProductionEvent.run_id.in_(run_ids)).subquery()
|
||||||
|
ev_rows = (await db.execute(
|
||||||
|
select(ev_sub.c.run_id, ev_sub.c.event_type, ev_sub.c.created_at, ev_sub.c.note)
|
||||||
|
.where(ev_sub.c.rn == 1)
|
||||||
|
)).all()
|
||||||
|
last_event = {r.run_id: r for r in ev_rows}
|
||||||
|
|
||||||
|
# Last N measurements per run, joined to their subtask for the limits.
|
||||||
|
m_rank = func.row_number().over(
|
||||||
|
partition_by=Measurement.production_run_id,
|
||||||
|
order_by=(Measurement.measured_at.desc(), Measurement.id.desc()),
|
||||||
|
).label("rn")
|
||||||
|
m_sub = select(
|
||||||
|
Measurement.production_run_id.label("run_id"),
|
||||||
|
Measurement.subtask_id.label("subtask_id"),
|
||||||
|
Measurement.value.label("value"),
|
||||||
|
Measurement.pass_fail.label("pass_fail"),
|
||||||
|
m_rank,
|
||||||
|
).where(Measurement.production_run_id.in_(run_ids)).subquery()
|
||||||
|
m_rows = (await db.execute(
|
||||||
|
select(
|
||||||
|
m_sub.c.run_id, m_sub.c.subtask_id, m_sub.c.value, m_sub.c.pass_fail,
|
||||||
|
RecipeSubtask.description, RecipeSubtask.unit,
|
||||||
|
RecipeSubtask.ltl, RecipeSubtask.utl,
|
||||||
|
)
|
||||||
|
.join(RecipeSubtask, RecipeSubtask.id == m_sub.c.subtask_id)
|
||||||
|
.where(m_sub.c.rn <= MONITOR_WINDOW)
|
||||||
|
)).all()
|
||||||
|
by_run = defaultdict(list)
|
||||||
|
for row in m_rows:
|
||||||
|
by_run[row.run_id].append(row)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for run in runs:
|
||||||
|
rows = by_run.get(run.id, [])
|
||||||
|
per_sub: dict[int, list] = defaultdict(list)
|
||||||
|
for row in rows:
|
||||||
|
per_sub[row.subtask_id].append(row)
|
||||||
|
subtasks = []
|
||||||
|
for sid, group in sorted(per_sub.items()):
|
||||||
|
mean = sum(float(g.value) for g in group) / len(group)
|
||||||
|
first = group[0]
|
||||||
|
subtasks.append(MonitorSubtask(
|
||||||
|
subtask_id=sid, description=first.description, unit=first.unit,
|
||||||
|
count=len(group), mean=round(mean, 6),
|
||||||
|
ltl=float(first.ltl) if first.ltl is not None else None,
|
||||||
|
utl=float(first.utl) if first.utl is not None else None,
|
||||||
|
in_tolerance=_in_tolerance(mean, first.ltl, first.utl),
|
||||||
|
))
|
||||||
|
timer = production_service.describe(run)
|
||||||
|
ev = last_event.get(run.id)
|
||||||
|
operator = users.get(run.operator_id)
|
||||||
|
result.append(MonitorRun(
|
||||||
|
id=run.id,
|
||||||
|
station_code=run.station.code, station_name=run.station.name,
|
||||||
|
recipe_code=run.recipe.code, recipe_name=run.recipe.name,
|
||||||
|
version_number=run.version.version_number,
|
||||||
|
operator=(operator.display_name or operator.username) if operator else "",
|
||||||
|
status=run.status,
|
||||||
|
started_at=run.started_at,
|
||||||
|
duration_seconds=max(0, int((timer["server_time"] - run.started_at).total_seconds())),
|
||||||
|
cycle_count=run.cycle_count,
|
||||||
|
seconds_to_next_measurement=timer["seconds_to_next_measurement"],
|
||||||
|
overdue=timer["overdue"],
|
||||||
|
last_event=MonitorEvent(
|
||||||
|
event_type=ev.event_type, created_at=ev.created_at, note=ev.note,
|
||||||
|
) if ev else None,
|
||||||
|
measured=len(rows),
|
||||||
|
pass_count=sum(1 for g in rows if g.pass_fail == "pass"),
|
||||||
|
warning_count=sum(1 for g in rows if g.pass_fail == "warning"),
|
||||||
|
fail_count=sum(1 for g in rows if g.pass_fail == "fail"),
|
||||||
|
subtasks=subtasks,
|
||||||
|
server_time=timer["server_time"],
|
||||||
|
))
|
||||||
|
return result
|
||||||
@@ -14,6 +14,7 @@ import csv
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.utils.tempo import iso_utc, to_local, utcnow
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
@@ -169,9 +170,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
|
|||||||
measurement.input_method,
|
measurement.input_method,
|
||||||
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
|
||||||
measurement.measured_by,
|
measurement.measured_by,
|
||||||
measurement.measured_at.isoformat() if measurement.measured_at else "",
|
iso_utc(measurement.measured_at),
|
||||||
measurement.supervisor_id or "",
|
measurement.supervisor_id or "",
|
||||||
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
|
iso_utc(measurement.authorised_at),
|
||||||
])
|
])
|
||||||
|
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
@@ -191,7 +192,7 @@ async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | N
|
|||||||
directory = Path(settings.upload_path) / EXPORT_SUBDIR
|
directory = Path(settings.upload_path) / EXPORT_SUBDIR
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
|
stamp = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S")
|
||||||
filename = f"production_{run.id}_{stamp}.csv"
|
filename = f"production_{run.id}_{stamp}.csv"
|
||||||
# A large run's file can take a moment to write: off the event loop, so
|
# A large run's file can take a moment to write: off the event loop, so
|
||||||
# closing one run does not stall every other request being served.
|
# closing one run does not stall every other request being served.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ the countdown would be off by the UTC offset. The number is the contract; the
|
|||||||
timestamp is only there for the audit trail.
|
timestamp is only there for the audit trail.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.utils.tempo import utcnow
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -34,10 +35,10 @@ from src.backend.services import (
|
|||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
"""Single source of time for runs.
|
"""Single source of time for runs.
|
||||||
|
|
||||||
Naive local time, matching what the database writes for ``func.now()`` defaults,
|
Naive UTC, matching what the database writes for ``func.now()`` defaults,
|
||||||
so a run's started_at and its deadline are comparable.
|
so a run's started_at and its deadline are comparable.
|
||||||
"""
|
"""
|
||||||
return datetime.now()
|
return utcnow()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ async def _copy_tasks_to_version(
|
|||||||
# a camera_measure task unable to execute from the moment a
|
# a camera_measure task unable to execute from the moment a
|
||||||
# recipe with measurements is next edited.
|
# recipe with measurements is next edited.
|
||||||
vision_json=task.vision_json,
|
vision_json=task.vision_json,
|
||||||
|
# Cartella immutabile: basta il riferimento (#46).
|
||||||
|
vision_recipe_dir=task.vision_recipe_dir,
|
||||||
)
|
)
|
||||||
db.add(new_task)
|
db.add(new_task)
|
||||||
await db.flush() # get new_task.id
|
await db.flush() # get new_task.id
|
||||||
@@ -253,6 +255,78 @@ async def create_recipe(
|
|||||||
return result.scalar_one()
|
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(
|
async def create_new_version(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from src.backend.utils.tempo import to_local, utcnow
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
@@ -32,6 +33,11 @@ _jinja_env = Environment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _giorno_locale(dt: datetime) -> str:
|
||||||
|
"""UTC naive filter bound -> local calendar day (YYYY-MM-DD)."""
|
||||||
|
return to_local(dt).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
async def _query_measurements(
|
async def _query_measurements(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
@@ -42,6 +48,7 @@ async def _query_measurements(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None, # #58
|
||||||
) -> list[Measurement]:
|
) -> list[Measurement]:
|
||||||
"""Query measurements with common filters (duplicated from statistics router)."""
|
"""Query measurements with common filters (duplicated from statistics router)."""
|
||||||
filters = []
|
filters = []
|
||||||
@@ -67,6 +74,9 @@ async def _query_measurements(
|
|||||||
if serial_number is not None:
|
if serial_number is not None:
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
|
||||||
|
if production_run_id is not None:
|
||||||
|
filters.append(Measurement.production_run_id == production_run_id)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Measurement)
|
select(Measurement)
|
||||||
.where(and_(*filters) if filters else True)
|
.where(and_(*filters) if filters else True)
|
||||||
@@ -275,6 +285,7 @@ async def generate_spc_report(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None, # #58
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Generate SPC PDF report and return bytes.
|
"""Generate SPC PDF report and return bytes.
|
||||||
|
|
||||||
@@ -284,6 +295,7 @@ async def generate_spc_report(
|
|||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Compute SPC data
|
# 2. Compute SPC data
|
||||||
@@ -323,13 +335,15 @@ async def generate_spc_report(
|
|||||||
if version_id:
|
if version_id:
|
||||||
filters_desc.append(f"Version: {version_id}")
|
filters_desc.append(f"Version: {version_id}")
|
||||||
if date_from:
|
if date_from:
|
||||||
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"From: {_giorno_locale(date_from)}")
|
||||||
if date_to:
|
if date_to:
|
||||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||||
if lot_number:
|
if lot_number:
|
||||||
filters_desc.append(f"Lot: {lot_number}")
|
filters_desc.append(f"Lot: {lot_number}")
|
||||||
if serial_number:
|
if serial_number:
|
||||||
filters_desc.append(f"Serial: {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
|
# 6. Render HTML template
|
||||||
template = _jinja_env.get_template("spc_report.html")
|
template = _jinja_env.get_template("spc_report.html")
|
||||||
@@ -342,7 +356,7 @@ async def generate_spc_report(
|
|||||||
control_chart_svg=control_chart_svg,
|
control_chart_svg=control_chart_svg,
|
||||||
histogram_svg=histogram_svg,
|
histogram_svg=histogram_svg,
|
||||||
filters_desc=filters_desc,
|
filters_desc=filters_desc,
|
||||||
generated_at=datetime.now(),
|
generated_at=to_local(utcnow()),
|
||||||
n_measurements=len(measurements),
|
n_measurements=len(measurements),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -361,12 +375,14 @@ async def generate_measurement_report(
|
|||||||
operator_id: int | None = None,
|
operator_id: int | None = None,
|
||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
|
production_run_id: int | None = None, # #58
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Generate measurement table PDF report and return bytes."""
|
"""Generate measurement table PDF report and return bytes."""
|
||||||
# 1. Query measurements
|
# 1. Query measurements
|
||||||
measurements = await _query_measurements(
|
measurements = await _query_measurements(
|
||||||
db, recipe_id, version_id, subtask_id,
|
db, recipe_id, version_id, subtask_id,
|
||||||
date_from, date_to, operator_id, lot_number, serial_number,
|
date_from, date_to, operator_id, lot_number, serial_number,
|
||||||
|
production_run_id=production_run_id, # #58
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Get recipe info
|
# 2. Get recipe info
|
||||||
@@ -395,7 +411,7 @@ async def generate_measurement_report(
|
|||||||
"value": f"{float(m.value):.4f}",
|
"value": f"{float(m.value):.4f}",
|
||||||
"unit": st_info.get("unit", "mm"),
|
"unit": st_info.get("unit", "mm"),
|
||||||
"pass_fail": m.pass_fail,
|
"pass_fail": m.pass_fail,
|
||||||
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"),
|
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
|
||||||
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
|
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
|
||||||
"lot_number": m.lot_number or "—",
|
"lot_number": m.lot_number or "—",
|
||||||
"serial_number": m.serial_number or "—",
|
"serial_number": m.serial_number or "—",
|
||||||
@@ -414,13 +430,15 @@ async def generate_measurement_report(
|
|||||||
if version_id:
|
if version_id:
|
||||||
filters_desc.append(f"Version: {version_id}")
|
filters_desc.append(f"Version: {version_id}")
|
||||||
if date_from:
|
if date_from:
|
||||||
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"From: {_giorno_locale(date_from)}")
|
||||||
if date_to:
|
if date_to:
|
||||||
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
|
filters_desc.append(f"To: {_giorno_locale(date_to)}")
|
||||||
if lot_number:
|
if lot_number:
|
||||||
filters_desc.append(f"Lot: {lot_number}")
|
filters_desc.append(f"Lot: {lot_number}")
|
||||||
if serial_number:
|
if serial_number:
|
||||||
filters_desc.append(f"Serial: {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
|
# 7. Render template
|
||||||
template = _jinja_env.get_template("measurement_report.html")
|
template = _jinja_env.get_template("measurement_report.html")
|
||||||
@@ -430,7 +448,7 @@ async def generate_measurement_report(
|
|||||||
rows=rows,
|
rows=rows,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
filters_desc=filters_desc,
|
filters_desc=filters_desc,
|
||||||
generated_at=datetime.now(),
|
generated_at=to_local(utcnow()),
|
||||||
n_measurements=len(measurements),
|
n_measurements=len(measurements),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ to the worker over HTTP and stays ignorant of how a number was produced.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -46,7 +47,13 @@ def _is_measurable(value: object) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
async def _call_worker(
|
||||||
|
image_bytes: bytes,
|
||||||
|
graph: dict,
|
||||||
|
*,
|
||||||
|
recipe_dir: str | None = None,
|
||||||
|
calibration: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
"""The one place that talks to the worker; the tests replace it.
|
"""The 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
|
The worker's fault must not become the API's fault (spec's edge-case
|
||||||
@@ -60,12 +67,19 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
|||||||
is not the worker's JSON contract must not turn into a bare KeyError
|
is not the worker's JSON contract must not turn into a bare KeyError
|
||||||
further down.
|
further down.
|
||||||
"""
|
"""
|
||||||
|
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
|
||||||
|
# si mandano, il worker usa i suoi default.
|
||||||
|
data = {"graph": json.dumps(graph)}
|
||||||
|
if recipe_dir:
|
||||||
|
data["recipe_dir"] = recipe_dir
|
||||||
|
if calibration:
|
||||||
|
data["calibration"] = json.dumps(calibration)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{settings.vision_worker_url}/run",
|
f"{settings.vision_worker_url}/run",
|
||||||
files={"image": ("frame.png", image_bytes, "image/png")},
|
files={"image": ("frame.png", image_bytes, "image/png")},
|
||||||
data={"graph": json.dumps(graph)},
|
data=data,
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -122,6 +136,188 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
|
|||||||
return report
|
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(
|
async def execute_task(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
task: RecipeTask,
|
task: RecipeTask,
|
||||||
@@ -132,8 +328,9 @@ async def execute_task(
|
|||||||
lot_number: str | None = None,
|
lot_number: str | None = None,
|
||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
production_run_id: int | None = None,
|
production_run_id: int | None = None,
|
||||||
) -> tuple[VisionResult, list[Measurement]]:
|
image_ext: str = "png",
|
||||||
if task.task_type != "camera_measure":
|
) -> tuple[VisionResult, list[Measurement], dict]:
|
||||||
|
if task.task_type not in VISION_TASK_TYPES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail=f"task type {task.task_type!r} is not a camera measurement",
|
detail=f"task type {task.task_type!r} is not a camera measurement",
|
||||||
@@ -210,7 +407,11 @@ async def execute_task(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
report = await _call_worker(image_bytes, task.vision_json)
|
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
|
||||||
|
# passa `calibration`, la ricetta porta la sua (#46).
|
||||||
|
report = await _call_worker(
|
||||||
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
|
)
|
||||||
outputs = report["outputs"]
|
outputs = report["outputs"]
|
||||||
|
|
||||||
# Every quote or none. A half-measured piece read as a whole one is worse
|
# Every quote or none. A half-measured piece read as a whole one is worse
|
||||||
@@ -231,8 +432,12 @@ async def execute_task(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
overlay = report.get("overlay") or []
|
||||||
|
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
|
||||||
result = VisionResult(
|
result = VisionResult(
|
||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
|
image_path=image_path,
|
||||||
|
overlay_path=overlay_path,
|
||||||
engine_version=report["engine_version"],
|
engine_version=report["engine_version"],
|
||||||
executed_on="server",
|
executed_on="server",
|
||||||
graph_snapshot=task.vision_json,
|
graph_snapshot=task.vision_json,
|
||||||
@@ -257,7 +462,7 @@ async def execute_task(
|
|||||||
enforce_tolerance_gate=False,
|
enforce_tolerance_gate=False,
|
||||||
))
|
))
|
||||||
|
|
||||||
return result, saved
|
return result, saved, report
|
||||||
|
|
||||||
|
|
||||||
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
def _reference_file_path(reference: VisionReferenceImage) -> Path:
|
||||||
@@ -377,4 +582,6 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
|
|||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail="this task carries no vision graph",
|
detail="this task carries no vision graph",
|
||||||
)
|
)
|
||||||
return await _call_worker(image_bytes, task.vision_json)
|
return await _call_worker(
|
||||||
|
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||||
|
)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ from src.backend.models.orm.measurement import Measurement
|
|||||||
from src.backend.models.orm.access_log import AccessLog
|
from src.backend.models.orm.access_log import AccessLog
|
||||||
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
|
||||||
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
from src.backend.models.orm.station import Station, StationRecipeAssignment
|
||||||
from src.backend.services.auth_service import hash_password, generate_api_key
|
from src.backend.services.auth_service import hash_password, issue_api_key
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# In-memory SQLite engine for tests
|
# In-memory SQLite engine for tests
|
||||||
@@ -142,7 +142,6 @@ async def _create_user(
|
|||||||
active: bool = True,
|
active: bool = True,
|
||||||
) -> User:
|
) -> User:
|
||||||
"""Insert a user into the test DB and return it with an API key set."""
|
"""Insert a user into the test DB and return it with an API key set."""
|
||||||
api_key = generate_api_key()
|
|
||||||
user = User(
|
user = User(
|
||||||
username=username,
|
username=username,
|
||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
@@ -150,13 +149,14 @@ async def _create_user(
|
|||||||
roles=roles or [],
|
roles=roles or [],
|
||||||
is_admin=is_admin,
|
is_admin=is_admin,
|
||||||
active=active,
|
active=active,
|
||||||
api_key=api_key,
|
|
||||||
language_pref="en",
|
language_pref="en",
|
||||||
theme_pref="light",
|
theme_pref="light",
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
|
# Clear key of this test session; the DB only keeps its hash.
|
||||||
|
user.plain_key = await issue_api_key(session, user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -278,4 +278,4 @@ async def create_test_recipe(
|
|||||||
|
|
||||||
def auth_headers(user: User) -> dict[str, str]:
|
def auth_headers(user: User) -> dict[str, str]:
|
||||||
"""Return headers dict with the user's API key for authenticated requests."""
|
"""Return headers dict with the user's API key for authenticated requests."""
|
||||||
return {"X-API-Key": user.api_key}
|
return {"X-API-Key": user.plain_key}
|
||||||
|
|||||||
+151
-46
@@ -78,75 +78,114 @@ class TestLogin:
|
|||||||
assert len(api_key) >= 32
|
assert len(api_key) >= 32
|
||||||
|
|
||||||
|
|
||||||
class TestLoginKeyReuse:
|
class TestLoginKeyPerSession:
|
||||||
"""login_user reuses an existing api_key instead of rotating it (#25)."""
|
"""Each login issues its own key; none revokes another (#25, #41)."""
|
||||||
|
|
||||||
async def test_two_logins_same_user_share_api_key(
|
async def test_two_logins_same_user_get_distinct_valid_keys(
|
||||||
self, client: AsyncClient, db_session
|
self, client: AsyncClient, db_session
|
||||||
):
|
):
|
||||||
"""Two stations logging in as the same shift account both keep working."""
|
|
||||||
await _create_user(db_session, username="twostations", password="TwoStations1")
|
await _create_user(db_session, username="twostations", password="TwoStations1")
|
||||||
resp1 = await client.post(
|
creds = {"username": "twostations", "password": "TwoStations1"}
|
||||||
"/api/auth/login",
|
resp1 = await client.post("/api/auth/login", json=creds)
|
||||||
json={"username": "twostations", "password": "TwoStations1"},
|
resp2 = await client.post("/api/auth/login", json=creds)
|
||||||
)
|
k1, k2 = resp1.json()["api_key"], resp2.json()["api_key"]
|
||||||
resp2 = await client.post(
|
assert k1 != k2
|
||||||
"/api/auth/login",
|
for k in (k1, k2):
|
||||||
json={"username": "twostations", "password": "TwoStations1"},
|
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
|
||||||
)
|
assert resp.status_code == 200
|
||||||
assert resp1.status_code == 200
|
|
||||||
assert resp2.status_code == 200
|
|
||||||
assert resp1.json()["api_key"] == resp2.json()["api_key"]
|
|
||||||
|
|
||||||
async def test_login_generates_key_when_user_has_none(
|
async def test_db_stores_hash_never_clear_key(
|
||||||
self, client: AsyncClient, db_session
|
self, client: AsyncClient, db_session
|
||||||
):
|
):
|
||||||
"""A user created without an api_key (e.g. via create_user) still gets one."""
|
from sqlalchemy import select
|
||||||
from src.backend.services.auth_service import create_user
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import hash_api_key
|
||||||
|
|
||||||
await create_user(
|
await _create_user(db_session, username="hashed", password="HashedPass1")
|
||||||
db_session, username="brandnew", password="BrandNew1",
|
|
||||||
display_name="Brand New",
|
|
||||||
)
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login", json={"username": "hashed", "password": "HashedPass1"}
|
||||||
json={"username": "brandnew", "password": "BrandNew1"},
|
|
||||||
)
|
)
|
||||||
|
key = resp.json()["api_key"]
|
||||||
|
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
|
||||||
|
assert hash_api_key(key) in hashes
|
||||||
|
assert key not in hashes
|
||||||
|
assert all(len(h) == 64 for h in hashes)
|
||||||
|
|
||||||
|
async def test_expired_key_gives_401(self, client: AsyncClient, db_session):
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import update
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import utcnow
|
||||||
|
|
||||||
|
user = await _create_user(db_session, username="expired", password="ExpiredPass1")
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
|
||||||
|
)
|
||||||
|
resp = await client.get("/api/auth/me", headers=auth_headers(user))
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "expired" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
async def test_use_renews_expiry_only_after_interval(
|
||||||
|
self, client: AsyncClient, db_session
|
||||||
|
):
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import utcnow
|
||||||
|
|
||||||
|
user = await _create_user(db_session, username="renew", password="RenewPass1")
|
||||||
|
headers = auth_headers(user)
|
||||||
|
old = utcnow() - timedelta(hours=10)
|
||||||
|
|
||||||
|
# Recent use: no write, expiry untouched.
|
||||||
|
recent = utcnow() - timedelta(minutes=1)
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(expires_at=old + timedelta(hours=24), last_used_at=recent)
|
||||||
|
)
|
||||||
|
await client.get("/api/auth/me", headers=headers)
|
||||||
|
row = (await db_session.execute(select(ApiKey))).scalar_one()
|
||||||
|
await db_session.refresh(row)
|
||||||
|
assert row.last_used_at == recent
|
||||||
|
|
||||||
|
# Stale last use: expiry is pushed forward.
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(last_used_at=utcnow() - timedelta(minutes=10))
|
||||||
|
)
|
||||||
|
before = utcnow()
|
||||||
|
resp = await client.get("/api/auth/me", headers=headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["api_key"]
|
await db_session.refresh(row)
|
||||||
|
assert row.expires_at >= before + timedelta(hours=23, minutes=59)
|
||||||
|
|
||||||
|
|
||||||
class TestLogout:
|
class TestLogout:
|
||||||
"""POST /api/auth/logout is a no-op on the shared api_key by default (#25)."""
|
"""Logout deletes only this session's key (#41)."""
|
||||||
|
|
||||||
async def test_logout_default_keeps_api_key_valid(
|
async def test_logout_removes_only_own_key(self, client: AsyncClient, db_session):
|
||||||
self, client: AsyncClient, db_session
|
await _create_user(db_session, username="shared_logout", password="SharedPass1")
|
||||||
):
|
creds = {"username": "shared_logout", "password": "SharedPass1"}
|
||||||
user = await _create_user(
|
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
db_session, username="shared_logout", password="SharedPass1"
|
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
)
|
resp = await client.post("/api/auth/logout", headers={"X-API-Key": k1})
|
||||||
headers = auth_headers(user)
|
|
||||||
resp = await client.post("/api/auth/logout", headers=headers)
|
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
|
|
||||||
# The key is still valid: another station on the same account keeps working.
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": k1})).status_code == 401
|
||||||
resp = await client.get("/api/auth/me", headers=headers)
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": k2})).status_code == 200
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
async def test_logout_all_sessions_invalidates_api_key(
|
async def test_logout_all_sessions_removes_every_key(
|
||||||
self, client: AsyncClient, db_session
|
self, client: AsyncClient, db_session
|
||||||
):
|
):
|
||||||
user = await _create_user(
|
await _create_user(db_session, username="shared_logout_all", password="SharedPass1")
|
||||||
db_session, username="shared_logout_all", password="SharedPass1"
|
creds = {"username": "shared_logout_all", "password": "SharedPass1"}
|
||||||
)
|
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
headers = auth_headers(user)
|
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/auth/logout", params={"all_sessions": "true"}, headers=headers
|
"/api/auth/logout", params={"all_sessions": "true"},
|
||||||
|
headers={"X-API-Key": k1},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
|
for k in (k1, k2):
|
||||||
resp = await client.get("/api/auth/me", headers=headers)
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": k})).status_code == 401
|
||||||
assert resp.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
class TestAuthenticateUserTiming:
|
class TestAuthenticateUserTiming:
|
||||||
@@ -185,3 +224,69 @@ class TestHealthAndAuth:
|
|||||||
resp = await client.get("/api/users")
|
resp = await client.get("/api/users")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
|
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRevokeAllKeys:
|
||||||
|
"""Password change, deactivation and regenerate invalidate every key (#41)."""
|
||||||
|
|
||||||
|
async def _two_keys(self, client, db_session, name):
|
||||||
|
user = await _create_user(db_session, username=name, password="KeysPass123")
|
||||||
|
creds = {"username": name, "password": "KeysPass123"}
|
||||||
|
keys = [
|
||||||
|
(await client.post("/api/auth/login", json=creds)).json()["api_key"]
|
||||||
|
for _ in range(2)
|
||||||
|
]
|
||||||
|
return user, keys
|
||||||
|
|
||||||
|
async def _all_401(self, client, keys):
|
||||||
|
for k in keys:
|
||||||
|
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
async def test_password_change_invalidates_all(self, client, db_session, admin_user):
|
||||||
|
user, keys = await self._two_keys(client, db_session, "pwkeys")
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/users/{user.id}/password",
|
||||||
|
headers=auth_headers(admin_user), json={"password": "NewSecurePass1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
await self._all_401(client, keys)
|
||||||
|
|
||||||
|
async def test_deactivation_invalidates_all(self, client, db_session, admin_user):
|
||||||
|
user, keys = await self._two_keys(client, db_session, "offkeys")
|
||||||
|
resp = await client.delete(
|
||||||
|
f"/api/users/{user.id}", headers=auth_headers(admin_user)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
await self._all_401(client, keys)
|
||||||
|
|
||||||
|
async def test_regenerate_invalidates_old_keys(self, client, db_session, admin_user):
|
||||||
|
user, keys = await self._two_keys(client, db_session, "regenkeys")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/users/{user.id}/regenerate-key", headers=auth_headers(admin_user)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
await self._all_401(client, keys)
|
||||||
|
new = resp.json()["api_key"]
|
||||||
|
assert (await client.get("/api/auth/me", headers={"X-API-Key": new})).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpiredKeyCleanup:
|
||||||
|
async def test_expired_key_removed_at_next_login(self, client, db_session):
|
||||||
|
from datetime import timedelta
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from src.backend.models.orm.api_key import ApiKey
|
||||||
|
from src.backend.services.auth_service import hash_api_key, utcnow
|
||||||
|
|
||||||
|
user = await _create_user(db_session, username="cleanup", password="CleanPass123")
|
||||||
|
old_hash = hash_api_key(user.plain_key)
|
||||||
|
await db_session.execute(
|
||||||
|
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/auth/login", json={"username": "cleanup", "password": "CleanPass123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
|
||||||
|
assert old_hash not in hashes
|
||||||
|
assert len(hashes) == 1
|
||||||
|
|||||||
@@ -107,3 +107,45 @@ class TestBaseMigrationNoopsOnExistingSchema:
|
|||||||
assert "image_path" in {
|
assert "image_path" in {
|
||||||
c["name"] for c in insp.get_columns("recipes")
|
c["name"] for c in insp.get_columns("recipes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiKeyHashMigration:
|
||||||
|
"""015_api_key_hash: users.api_key -> api_keys table, and back (#41)."""
|
||||||
|
|
||||||
|
def test_015_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" in insp.get_table_names()
|
||||||
|
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
assert {c["name"] for c in insp.get_columns("api_keys")} >= {
|
||||||
|
"user_id", "key_hash", "created_at", "expires_at", "last_used_at",
|
||||||
|
}
|
||||||
|
|
||||||
|
command.downgrade(cfg, "014_vision_fk_ondelete")
|
||||||
|
insp = _inspect(db_path)
|
||||||
|
assert "api_keys" not in insp.get_table_names()
|
||||||
|
assert "api_key" in {c["name"] for c in insp.get_columns("users")}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head") # and up again
|
||||||
|
assert "api_keys" in _inspect(db_path).get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
class TestVisionRecipeDirMigration:
|
||||||
|
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
|
||||||
|
|
||||||
|
def test_016_upgrade_and_downgrade(self, sqlite_database_url):
|
||||||
|
db_path, _ = sqlite_database_url
|
||||||
|
cfg = _alembic_config()
|
||||||
|
|
||||||
|
def cols():
|
||||||
|
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
|
||||||
|
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
assert "vision_recipe_dir" in cols()
|
||||||
|
command.downgrade(cfg, "015_api_key_hash")
|
||||||
|
assert "vision_recipe_dir" not in cols()
|
||||||
|
command.upgrade(cfg, "head") # e di nuovo su
|
||||||
|
assert "vision_recipe_dir" in cols()
|
||||||
|
|||||||
@@ -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,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
|
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(
|
async def test_unknown_key_not_validated(
|
||||||
self, client: AsyncClient, admin_user: User
|
self, client: AsyncClient, admin_user: User
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -211,3 +211,79 @@ class TestDateFilter:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# All measurements should be within this wide range
|
# All measurements should be within this wide range
|
||||||
assert resp.json()["total"] >= 2
|
assert resp.json()["total"] >= 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunFilter:
|
||||||
|
"""#58: statistiche per sessione di produzione."""
|
||||||
|
|
||||||
|
async def _setup(self, db_session, user, metro):
|
||||||
|
from datetime import datetime
|
||||||
|
from src.backend.models.orm.production import ProductionRun
|
||||||
|
from src.backend.models.orm.station import Station
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from sqlalchemy import select
|
||||||
|
recipe = await create_test_recipe(db_session, user.id)
|
||||||
|
version = (await db_session.execute(
|
||||||
|
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
|
||||||
|
)).scalars().first()
|
||||||
|
station = Station(code="ST-58", name="Stazione 58", active=True, created_by=user.id)
|
||||||
|
db_session.add(station)
|
||||||
|
await db_session.commit()
|
||||||
|
runs = []
|
||||||
|
for i, lot in enumerate(["L1", "L2"]):
|
||||||
|
run = ProductionRun(
|
||||||
|
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
|
||||||
|
operator_id=user.id, status="closed", lot_number=lot,
|
||||||
|
started_at=datetime(2026, 1, 1 + i),
|
||||||
|
)
|
||||||
|
db_session.add(run)
|
||||||
|
runs.append(run)
|
||||||
|
await db_session.commit()
|
||||||
|
return recipe, runs
|
||||||
|
|
||||||
|
async def test_filter_by_run_and_unfiltered(
|
||||||
|
self, client, measurement_tec_user, metrologist_user, db_session,
|
||||||
|
):
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
recipe, runs = await self._setup(db_session, measurement_tec_user, metrologist_user)
|
||||||
|
subtask_id, version_id = await _seed_measurements(
|
||||||
|
client, measurement_tec_user, recipe.id, [10.0, 10.1, 10.2],
|
||||||
|
)
|
||||||
|
# Assegna le prime due misure al run 1, la terza al run 2.
|
||||||
|
from sqlalchemy import select
|
||||||
|
ms = (await db_session.execute(
|
||||||
|
select(Measurement).order_by(Measurement.id)
|
||||||
|
)).scalars().all()
|
||||||
|
ms[0].production_run_id = runs[0].id
|
||||||
|
ms[1].production_run_id = runs[0].id
|
||||||
|
ms[2].production_run_id = runs[1].id
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
h = auth_headers(metrologist_user)
|
||||||
|
r1 = await client.get("/api/statistics/summary", headers=h,
|
||||||
|
params={"recipe_id": recipe.id, "production_run_id": runs[0].id})
|
||||||
|
assert r1.json()["total"] == 2
|
||||||
|
r2 = await client.get("/api/statistics/summary", headers=h,
|
||||||
|
params={"recipe_id": recipe.id, "production_run_id": runs[1].id})
|
||||||
|
assert r2.json()["total"] == 1
|
||||||
|
r_all = await client.get("/api/statistics/summary", headers=h,
|
||||||
|
params={"recipe_id": recipe.id})
|
||||||
|
assert r_all.json()["total"] == 3
|
||||||
|
|
||||||
|
runs_resp = await client.get("/api/statistics/runs", headers=h,
|
||||||
|
params={"recipe_id": recipe.id})
|
||||||
|
assert runs_resp.status_code == 200
|
||||||
|
data = runs_resp.json()
|
||||||
|
assert [r["id"] for r in data] == [runs[1].id, runs[0].id] # più recente prima
|
||||||
|
assert [r["measurement_count"] for r in data] == [1, 2]
|
||||||
|
assert data[0]["lot_number"] == "L2"
|
||||||
|
assert data[0]["station_name"] == "Stazione 58"
|
||||||
|
assert data[0]["closed_at"] is None
|
||||||
|
|
||||||
|
async def test_runs_require_metrologist(
|
||||||
|
self, client, measurement_tec_user, db_session,
|
||||||
|
):
|
||||||
|
resp = await client.get("/api/statistics/runs",
|
||||||
|
headers=auth_headers(measurement_tec_user),
|
||||||
|
params={"recipe_id": 1})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""UTC in the DB, explicit +00:00 on the wire, local time only on presentation (#42)."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.orm.user import User
|
||||||
|
from src.backend.services import report_service
|
||||||
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
from src.backend.utils.tempo import as_utc_naive, to_local, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
def test_utcnow_naive_and_close_to_utc():
|
||||||
|
now = utcnow()
|
||||||
|
assert now.tzinfo is None
|
||||||
|
delta = datetime.now(timezone.utc).replace(tzinfo=None) - now
|
||||||
|
assert abs(delta.total_seconds()) < 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_as_utc_naive():
|
||||||
|
parsed = datetime.fromisoformat("2026-10-06T10:00:00+02:00")
|
||||||
|
assert as_utc_naive(parsed) == datetime(2026, 10, 6, 8, 0)
|
||||||
|
assert as_utc_naive(parsed).tzinfo is None
|
||||||
|
naive = datetime(2026, 10, 6, 8, 0)
|
||||||
|
assert as_utc_naive(naive) is naive
|
||||||
|
assert as_utc_naive(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_local_is_rome():
|
||||||
|
assert to_local(datetime(2026, 10, 6, 8, 0)).strftime("%H:%M %z") == "10:00 +0200"
|
||||||
|
assert to_local(datetime(2026, 1, 6, 8, 0)).strftime("%H:%M %z") == "09:00 +0100"
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_measurement(client, user, recipe_id) -> None:
|
||||||
|
tasks = (await client.get(f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user))).json()
|
||||||
|
await client.post(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(user),
|
||||||
|
json={
|
||||||
|
"subtask_id": tasks[0]["subtasks"][0]["id"],
|
||||||
|
"version_id": tasks[0]["version_id"],
|
||||||
|
"value": 10.0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_api_datetimes_carry_utc_offset_and_filter_equivalence(
|
||||||
|
client: AsyncClient,
|
||||||
|
measurement_tec_user: User,
|
||||||
|
metrologist_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
|
||||||
|
await _make_measurement(client, measurement_tec_user, recipe.id)
|
||||||
|
|
||||||
|
resp = await client.get("/api/measurements/", headers=auth_headers(metrologist_user))
|
||||||
|
item = resp.json()["items"][0]
|
||||||
|
assert item["measured_at"].endswith("+00:00")
|
||||||
|
|
||||||
|
# Same instant written with +02:00 and as UTC: identical results.
|
||||||
|
future_utc = "2099-01-01T08:00:00+00:00"
|
||||||
|
future_rome = "2099-01-01T10:00:00+02:00"
|
||||||
|
past_utc = "2000-01-01T08:00:00+00:00"
|
||||||
|
past_rome = "2000-01-01T10:00:00+02:00"
|
||||||
|
for frm, to in ((past_utc, future_utc), (past_rome, future_rome)):
|
||||||
|
r = await client.get(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(metrologist_user),
|
||||||
|
params={"date_from": frm, "date_to": to},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["total"] == 1
|
||||||
|
|
||||||
|
# Boundary: measurement is "now" in UTC; a date_from 1h ahead in UTC,
|
||||||
|
# written as +02:00 (now+3h local wall clock), must exclude it.
|
||||||
|
ahead = (utcnow().replace(microsecond=0)).replace(tzinfo=timezone.utc)
|
||||||
|
from datetime import timedelta
|
||||||
|
ahead_utc = (ahead + timedelta(hours=1)).isoformat()
|
||||||
|
ahead_rome = (ahead + timedelta(hours=1)).astimezone(
|
||||||
|
timezone(timedelta(hours=2))
|
||||||
|
).isoformat()
|
||||||
|
totals = []
|
||||||
|
for frm in (ahead_utc, ahead_rome):
|
||||||
|
r = await client.get(
|
||||||
|
"/api/measurements/",
|
||||||
|
headers=auth_headers(metrologist_user),
|
||||||
|
params={"date_from": frm},
|
||||||
|
)
|
||||||
|
totals.append(r.json()["total"])
|
||||||
|
assert totals == [0, 0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_report_generated_at_is_local_time(
|
||||||
|
db_session: AsyncSession, metrologist_user: User
|
||||||
|
):
|
||||||
|
recipe = await create_test_recipe(db_session, metrologist_user.id)
|
||||||
|
captured: dict[str, str] = {}
|
||||||
|
|
||||||
|
def fake_render(html: str) -> bytes:
|
||||||
|
captured["html"] = html
|
||||||
|
return b"%PDF"
|
||||||
|
|
||||||
|
with patch.object(report_service, "utcnow", return_value=datetime(2026, 10, 6, 8, 0)), \
|
||||||
|
patch.object(report_service, "_render_pdf", side_effect=fake_render):
|
||||||
|
await report_service.generate_measurement_report(db_session, recipe.id)
|
||||||
|
|
||||||
|
assert "Generated 2026-10-06 10:00" in captured["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_filter_day_is_local():
|
||||||
|
"""UTC naive 22:00 of Oct 5 is already Oct 6 in Europe/Rome (#42)."""
|
||||||
|
assert report_service._giorno_locale(datetime(2026, 10, 5, 22, 0, 0)) == "2026-10-06"
|
||||||
@@ -17,7 +17,7 @@ class TestChangeUserPassword:
|
|||||||
|
|
||||||
the same as setup.py's change-user-password (setup.py:634).
|
the same as setup.py's change-user-password (setup.py:634).
|
||||||
"""
|
"""
|
||||||
old_key = maker_user.api_key
|
old_key = maker_user.plain_key
|
||||||
old_username = maker_user.username
|
old_username = maker_user.username
|
||||||
assert old_key is not None
|
assert old_key is not None
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ class TestChangeUserPassword:
|
|||||||
|
|
||||||
# The old key no longer authenticates. Read it before the request
|
# The old key no longer authenticates. Read it before the request
|
||||||
# above: the client and app share one session (see conftest), so
|
# above: the client and app share one session (see conftest), so
|
||||||
# `maker_user.api_key` in this process reflects the mutation too.
|
# `maker_user.plain_key` in this process reflects the mutation too.
|
||||||
resp2 = await client.get("/api/auth/me", headers={"X-API-Key": old_key})
|
resp2 = await client.get("/api/auth/me", headers={"X-API-Key": old_key})
|
||||||
assert resp2.status_code == 401
|
assert resp2.status_code == 401
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ async def two_vision_quotes(db_session, recipe_id: int):
|
|||||||
def worker_says(monkeypatch):
|
def worker_says(monkeypatch):
|
||||||
"""The worker does not run in tests: its response is faked here."""
|
"""The worker does not run in tests: its response is faked here."""
|
||||||
def _install(outputs: dict, failures: list | None = None):
|
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 {
|
return {
|
||||||
"outputs": outputs,
|
"outputs": outputs,
|
||||||
"failures": failures or [],
|
"failures": failures or [],
|
||||||
@@ -316,7 +316,7 @@ async def test_a_pending_fail_blocks_before_the_worker_is_called(
|
|||||||
)
|
)
|
||||||
await db_session.commit()
|
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")
|
raise AssertionError("the worker must not run while the operator is blocked")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"src.backend.services.vision_service._call_worker", worker_must_not_be_called,
|
"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
|
@pytest.fixture
|
||||||
def worker_says(monkeypatch):
|
def worker_says(monkeypatch):
|
||||||
def _install(outputs: dict):
|
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": [],
|
return {"outputs": outputs, "failures": [],
|
||||||
"engine_version": "e" * 40, "duration_ms": 3}
|
"engine_version": "e" * 40, "duration_ms": 3}
|
||||||
monkeypatch.setattr(
|
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
|
@pytest.fixture
|
||||||
def worker_says(monkeypatch):
|
def worker_says(monkeypatch):
|
||||||
def _install(outputs: dict):
|
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": [],
|
return {"outputs": outputs, "failures": [],
|
||||||
"engine_version": "b" * 40, "duration_ms": 7}
|
"engine_version": "b" * 40, "duration_ms": 7}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ async def recipe_parts(db_session, recipe_id: int):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def worker_says(monkeypatch):
|
def worker_says(monkeypatch):
|
||||||
def _install(outputs: dict):
|
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": [],
|
return {"outputs": outputs, "failures": [],
|
||||||
"engine_version": "c" * 40, "duration_ms": 5}
|
"engine_version": "c" * 40, "duration_ms": 5}
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Time helpers: the DB holds naive UTC, presentation converts to display_timezone (#42)."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from src.backend.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def as_utc_naive(dt: datetime | None) -> datetime | None:
|
||||||
|
"""Aware -> UTC naive; naive is taken as UTC already; None stays None."""
|
||||||
|
if dt is None or dt.tzinfo is None:
|
||||||
|
return dt
|
||||||
|
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def to_utc_aware(dt: datetime) -> datetime:
|
||||||
|
"""Naive (UTC) or aware datetime -> aware UTC."""
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def iso_utc(dt: datetime | None) -> str:
|
||||||
|
"""ISO 8601 with explicit +00:00; "" for None (CSV cells)."""
|
||||||
|
return to_utc_aware(dt).isoformat() if dt else ""
|
||||||
|
|
||||||
|
|
||||||
|
def to_local(dt: datetime) -> datetime:
|
||||||
|
"""Naive UTC (or aware) datetime -> aware datetime in settings.display_timezone."""
|
||||||
|
return to_utc_aware(dt).astimezone(ZoneInfo(settings.display_timezone))
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from datetime import date
|
from datetime import date, datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import Flask, flash, g, jsonify, redirect, url_for, session, request
|
from flask import Flask, flash, g, jsonify, redirect, url_for, session, request
|
||||||
@@ -73,12 +74,14 @@ def create_app() -> Flask:
|
|||||||
from blueprints.maker import maker_bp
|
from blueprints.maker import maker_bp
|
||||||
from blueprints.statistics import statistics_bp
|
from blueprints.statistics import statistics_bp
|
||||||
from blueprints.admin import admin_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(auth_bp)
|
||||||
app.register_blueprint(measure_bp, url_prefix="/measure")
|
app.register_blueprint(measure_bp, url_prefix="/measure")
|
||||||
app.register_blueprint(maker_bp, url_prefix="/maker")
|
app.register_blueprint(maker_bp, url_prefix="/maker")
|
||||||
app.register_blueprint(statistics_bp, url_prefix="/statistics")
|
app.register_blueprint(statistics_bp, url_prefix="/statistics")
|
||||||
app.register_blueprint(admin_bp, url_prefix="/admin")
|
app.register_blueprint(admin_bp, url_prefix="/admin")
|
||||||
|
app.register_blueprint(monitor_bp, url_prefix="/monitor") # #57
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def _check_secret_key():
|
def _check_secret_key():
|
||||||
@@ -154,6 +157,26 @@ def create_app() -> Flask:
|
|||||||
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
|
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
|
||||||
return Markup(bolded.replace("\n", "<br>"))
|
return Markup(bolded.replace("\n", "<br>"))
|
||||||
|
|
||||||
|
@app.template_filter("local_dt")
|
||||||
|
def local_dt_filter(value, fmt="%d/%m/%Y %H:%M"):
|
||||||
|
"""Show a UTC datetime (ISO string or datetime) in DISPLAY_TIMEZONE.
|
||||||
|
|
||||||
|
A value without timezone is taken as UTC. None or empty gives "-".
|
||||||
|
"""
|
||||||
|
if value is None or value == "":
|
||||||
|
return "-"
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
if not isinstance(value, datetime):
|
||||||
|
return str(value)
|
||||||
|
if value.tzinfo is None:
|
||||||
|
value = value.replace(tzinfo=timezone.utc)
|
||||||
|
tz = ZoneInfo(app.config.get("DISPLAY_TIMEZONE") or "Europe/Rome")
|
||||||
|
return value.astimezone(tz).strftime(fmt)
|
||||||
|
|
||||||
@app.template_filter("tojson_attr")
|
@app.template_filter("tojson_attr")
|
||||||
def tojson_attr_filter(value):
|
def tojson_attr_filter(value):
|
||||||
"""JSON encode safe for HTML attributes (x-data, etc.).
|
"""JSON encode safe for HTML attributes (x-data, etc.).
|
||||||
|
|||||||
@@ -29,28 +29,30 @@ def admin_required(f):
|
|||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def user_list():
|
def user_list():
|
||||||
"""User management page."""
|
"""#50: la gestione utenti vive nella tab «Utenti» di Impostazioni."""
|
||||||
resp = api_client.get("/api/users")
|
return redirect(url_for("admin.settings_page", tab="utenti"))
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/settings")
|
@admin_bp.route("/settings")
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def settings_page():
|
def settings_page():
|
||||||
"""System settings page."""
|
"""System settings page (admin only), con la tab «Utenti» (#50)."""
|
||||||
resp = api_client.get("/api/settings")
|
resp = api_client.get("/api/settings")
|
||||||
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
|
# Solo un dict valido sono impostazioni: un errore o altro tipo = nessuna
|
||||||
return render_template("admin/settings.html", settings=settings)
|
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"])
|
@admin_bp.route("/api/settings", methods=["PUT"])
|
||||||
|
|||||||
@@ -59,6 +59,45 @@ def _post_login_landing():
|
|||||||
return url_for("auth.profile")
|
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"])
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
"""Login page and form handler."""
|
"""Login page and form handler."""
|
||||||
@@ -81,35 +120,7 @@ def login():
|
|||||||
flash(response.get("detail", _("Credenziali non valide")), "error")
|
flash(response.get("detail", _("Credenziali non valide")), "error")
|
||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
# Salva in sessione. permanent=True fa valere PERMANENT_SESSION_LIFETIME
|
user = _complete_login(response, username)
|
||||||
# (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")
|
|
||||||
|
|
||||||
# Redirect dopo login (in base al ruolo)
|
# Redirect dopo login (in base al ruolo)
|
||||||
return redirect(_post_login_landing())
|
return redirect(_post_login_landing())
|
||||||
@@ -121,6 +132,55 @@ def login():
|
|||||||
return render_template("auth/login.html")
|
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"])
|
@auth_bp.route("/logout", methods=["POST"])
|
||||||
def logout():
|
def logout():
|
||||||
"""Clear session and redirect to login."""
|
"""Clear session and redirect to login."""
|
||||||
|
|||||||
@@ -57,7 +57,20 @@ def recipe_list():
|
|||||||
@role_required("Maker")
|
@role_required("Maker")
|
||||||
def recipe_new():
|
def recipe_new():
|
||||||
"""Create new recipe."""
|
"""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")
|
@maker_bp.route("/recipes/<int:recipe_id>/edit")
|
||||||
@@ -201,6 +214,21 @@ def api_create_recipe():
|
|||||||
return jsonify(resp), 201
|
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"])
|
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required("Maker")
|
@role_required("Maker")
|
||||||
@@ -377,6 +405,47 @@ def api_upload_file():
|
|||||||
return jsonify(resp), 201
|
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"])
|
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
|
import requests as http_requests
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint, abort, flash, jsonify, redirect, render_template,
|
Blueprint, Response, abort, flash, jsonify, redirect, render_template,
|
||||||
request, session, url_for,
|
g, request, session, url_for,
|
||||||
)
|
)
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
@@ -652,6 +654,74 @@ def api_pending_authorisation():
|
|||||||
return jsonify(resp), 200
|
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"])
|
@measure_bp.route("/api/measurements/<int:measurement_id>/authorise", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@role_required("MeasurementTec")
|
@role_required("MeasurementTec")
|
||||||
@@ -663,20 +733,10 @@ def api_authorise_measurement(measurement_id: int):
|
|||||||
written down nowhere and nothing depending on it.
|
written down nowhere and nothing depending on it.
|
||||||
"""
|
"""
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
username = (data.get("supervisor_username") or "").strip()
|
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||||
password = data.get("supervisor_password") or ""
|
if error:
|
||||||
if not username or not password:
|
return error
|
||||||
return jsonify({
|
return _post_supervised(f"/api/measurements/{measurement_id}/authorise", creds)
|
||||||
"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
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -768,24 +828,12 @@ def _supervised_action(run_id: int, action: str):
|
|||||||
leave no such record, and is an extra round trip besides.
|
leave no such record, and is an extra round trip besides.
|
||||||
"""
|
"""
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
username = (data.get("supervisor_username") or "").strip()
|
creds, error = _supervisor_credentials(data) # #59: PIN or password
|
||||||
password = data.get("supervisor_password") or ""
|
if error:
|
||||||
if not username or not password:
|
return error
|
||||||
return jsonify({
|
return _post_supervised(
|
||||||
"error": True, "detail": _("Username e password richiesti"),
|
f"/api/production-runs/{run_id}/{action}", creds, {"note": data.get("note")},
|
||||||
}), 400
|
|
||||||
|
|
||||||
resp = api_client.post(
|
|
||||||
f"/api/production-runs/{run_id}/{action}",
|
|
||||||
data={
|
|
||||||
"supervisor_username": username,
|
|
||||||
"supervisor_password": password,
|
|
||||||
"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"])
|
@measure_bp.route("/api/production/<int:run_id>/pause", methods=["POST"])
|
||||||
@@ -820,3 +868,67 @@ def api_close_production(run_id: int):
|
|||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||||
return proxy_file(file_path)
|
return proxy_file(file_path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route: vision execute (#46) - the browser sends an image, we relay it to
|
||||||
|
# POST /api/vision/execute (multipart) because it cannot send X-API-Key itself.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@measure_bp.route("/api/vision/execute", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_vision_execute():
|
||||||
|
"""Proxy: run the task's VisionSuite recipe on the uploaded image."""
|
||||||
|
image = request.files.get("image")
|
||||||
|
task_id = request.form.get("task_id")
|
||||||
|
if image is None or not task_id:
|
||||||
|
return jsonify({
|
||||||
|
"error": True,
|
||||||
|
"detail": _("Dati mancanti: task_id e image sono obbligatori"),
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Same context fields as the manual flow; empty ones are left out so the
|
||||||
|
# backend sees them as absent rather than as "".
|
||||||
|
form = {"task_id": task_id}
|
||||||
|
for key in ("task_type", "lot_number", "serial_number", "production_run_id"):
|
||||||
|
val = request.form.get(key)
|
||||||
|
if val:
|
||||||
|
form[key] = val
|
||||||
|
form.setdefault("lot_number", session.get("lot_number", ""))
|
||||||
|
form.setdefault("serial_number", session.get("serial_number", ""))
|
||||||
|
|
||||||
|
resp = api_client.post(
|
||||||
|
"/api/vision/execute",
|
||||||
|
data=form,
|
||||||
|
files={"image": (
|
||||||
|
image.filename or "capture.jpg",
|
||||||
|
image.read(),
|
||||||
|
image.mimetype or "application/octet-stream",
|
||||||
|
)},
|
||||||
|
)
|
||||||
|
if resp.get("error"):
|
||||||
|
status_code = resp.get("status_code", 500)
|
||||||
|
body = {"error": True, "detail": resp.get("detail", _("Errore nell'esecuzione"))}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
body["session_expired"] = True
|
||||||
|
return jsonify(body), status_code if status_code >= 400 else 500
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
@measure_bp.route("/api/vision/results/<int:result_id>/image", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def api_vision_result_image(result_id: int):
|
||||||
|
"""Proxy: the image saved with a vision result (needs X-API-Key)."""
|
||||||
|
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||||
|
resp = http_requests.get(
|
||||||
|
f"{base_url}/api/vision/results/{result_id}/image",
|
||||||
|
headers={"X-API-Key": session.get("api_key", "")},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return Response(resp.text, status=resp.status_code)
|
||||||
|
return Response(
|
||||||
|
resp.content,
|
||||||
|
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Live monitor of the open production runs (#57).
|
||||||
|
|
||||||
|
Page at /monitor for admin and Supervisor (capoturno); the page polls a JSON proxy
|
||||||
|
of GET /api/production-runs/open every 10 seconds.
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, jsonify, render_template, session
|
||||||
|
|
||||||
|
from blueprints.auth import login_required
|
||||||
|
from services.api_client import api_client
|
||||||
|
|
||||||
|
monitor_bp = Blueprint("monitor", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_required(f):
|
||||||
|
"""Admin or Supervisor only: the same people the backend lets read /open."""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
user = session.get("user", {})
|
||||||
|
if not (user.get("is_admin") or "Supervisor" in user.get("roles", [])):
|
||||||
|
abort(403)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
@monitor_bp.route("", strict_slashes=False)
|
||||||
|
@login_required
|
||||||
|
@monitor_required
|
||||||
|
def index():
|
||||||
|
return render_template("monitor/index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@monitor_bp.route("/api/runs")
|
||||||
|
@login_required
|
||||||
|
@monitor_required
|
||||||
|
def api_runs():
|
||||||
|
"""JSON proxy polled by the page."""
|
||||||
|
resp = api_client.get("/api/production-runs/open")
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
body = {"error": True, "detail": resp.get("detail", "")}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
body["session_expired"] = True
|
||||||
|
return jsonify(body), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp)
|
||||||
@@ -50,7 +50,7 @@ def _proxy_statistics(endpoint: str):
|
|||||||
for key in [
|
for key in [
|
||||||
"recipe_id", "version_id", "subtask_id",
|
"recipe_id", "version_id", "subtask_id",
|
||||||
"date_from", "date_to", "operator_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)
|
val = request.args.get(key)
|
||||||
if val is not None and val != "":
|
if val is not None and val != "":
|
||||||
@@ -127,6 +127,27 @@ def api_subtasks():
|
|||||||
return jsonify(resp)
|
return jsonify(resp)
|
||||||
|
|
||||||
|
|
||||||
|
@statistics_bp.route("/api/runs")
|
||||||
|
@login_required
|
||||||
|
@role_required("Metrologist")
|
||||||
|
def api_runs():
|
||||||
|
"""Proxy: production runs of a recipe (#58, "Sessione" filter)."""
|
||||||
|
params = {}
|
||||||
|
recipe_id = request.args.get("recipe_id")
|
||||||
|
if recipe_id:
|
||||||
|
params["recipe_id"] = recipe_id
|
||||||
|
|
||||||
|
resp = api_client.get("/api/statistics/runs", params=params)
|
||||||
|
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
error_body = {"error": True, "detail": resp.get("detail", "")}
|
||||||
|
if resp.get("session_expired"):
|
||||||
|
error_body["session_expired"] = True
|
||||||
|
return jsonify(error_body), resp.get("status_code", 500)
|
||||||
|
|
||||||
|
return jsonify(resp)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PROXY REPORT PDF → FastAPI
|
# PROXY REPORT PDF → FastAPI
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -137,7 +158,7 @@ def _proxy_report(endpoint: str):
|
|||||||
for key in [
|
for key in [
|
||||||
"recipe_id", "version_id", "subtask_id",
|
"recipe_id", "version_id", "subtask_id",
|
||||||
"date_from", "date_to", "operator_id",
|
"date_from", "date_to", "operator_id",
|
||||||
"lot_number", "serial_number",
|
"lot_number", "serial_number", "production_run_id", # #58
|
||||||
]:
|
]:
|
||||||
val = request.args.get(key)
|
val = request.args.get(key)
|
||||||
if val is not None and val != "":
|
if val is not None and val != "":
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ class Config:
|
|||||||
# Babel i18n
|
# Babel i18n
|
||||||
BABEL_DEFAULT_LOCALE = "it"
|
BABEL_DEFAULT_LOCALE = "it"
|
||||||
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
|
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
|
||||||
|
# Il DB e le API parlano UTC; si converte solo alla presentazione.
|
||||||
|
DISPLAY_TIMEZONE: str = os.getenv("DISPLAY_TIMEZONE", "Europe/Rome")
|
||||||
LANGUAGES = {"it": "Italiano", "en": "English"}
|
LANGUAGES = {"it": "Italiano", "en": "English"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ function csvExport() {
|
|||||||
*/
|
*/
|
||||||
generateFilename(prefix) {
|
generateFilename(prefix) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // YYYY-MM-DD locale
|
||||||
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
|
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
|
||||||
|
|
||||||
return `${prefix}_${dateStr}_${timeStr}.csv`;
|
return `${prefix}_${dateStr}_${timeStr}.csv`;
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* image-crop.js — ritaglio di immagine/PDF prima dell'upload (#56).
|
||||||
|
*
|
||||||
|
* Cosa fa: ImageCrop.open(file) apre una modale con un rettangolo di ritaglio
|
||||||
|
* trascinabile e ridimensionabile (mouse e touch, Pointer Events). Per un PDF
|
||||||
|
* rasterizza la pagina scelta con pdf.js (>= 2000 px sul lato lungo).
|
||||||
|
* Risolve: Blob PNG del ritaglio (a piena risoluzione) | il file originale
|
||||||
|
* ("Usa originale") | null ("Annulla").
|
||||||
|
* Come si usa: ImageCrop.configure({pdfSrc, workerSrc, labels}) una volta dal
|
||||||
|
* template (URL di vendor/pdf e stringhe tradotte), poi `await ImageCrop.open(file)`.
|
||||||
|
* Dipendenze: nessuna obbligatoria. pdf.js (gia' vendorizzato) viene caricato
|
||||||
|
* al bisogno solo per i PDF; il ritaglio usa un canvas 2D semplice invece di
|
||||||
|
* fabric.js perche' serve solo un rettangolo, non un editor di oggetti.
|
||||||
|
*/
|
||||||
|
(function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var cfg = {
|
||||||
|
pdfSrc: '',
|
||||||
|
workerSrc: '',
|
||||||
|
labels: {
|
||||||
|
title: 'Ritaglia immagine',
|
||||||
|
page: 'Pagina',
|
||||||
|
hint: 'Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.',
|
||||||
|
pdfNote: 'Un PDF ritagliato viene salvato come immagine PNG.',
|
||||||
|
confirm: 'Conferma ritaglio',
|
||||||
|
original: 'Usa originale',
|
||||||
|
cancel: 'Annulla',
|
||||||
|
loading: 'Caricamento...',
|
||||||
|
error: 'Impossibile aprire il file per il ritaglio.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var MIN_SIDE = 16; // lato minimo del ritaglio, in px immagine
|
||||||
|
var HANDLE = 12; // raggio di presa delle maniglie, in px schermo
|
||||||
|
var PDF_MIN_LONG = 2000; // lato lungo minimo del raster PDF
|
||||||
|
var PDF_MAX_LONG = 6000; // tetto per non esaurire la memoria del canvas
|
||||||
|
|
||||||
|
function configure(opts) {
|
||||||
|
if (!opts) return;
|
||||||
|
if (opts.pdfSrc) cfg.pdfSrc = opts.pdfSrc;
|
||||||
|
if (opts.workerSrc) cfg.workerSrc = opts.workerSrc;
|
||||||
|
if (opts.labels) for (var k in opts.labels) cfg.labels[k] = opts.labels[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdf(file) {
|
||||||
|
return file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPdfJs() {
|
||||||
|
if (global.pdfjsLib) return Promise.resolve(global.pdfjsLib);
|
||||||
|
if (!cfg.pdfSrc) return Promise.reject(new Error('pdf.js non configurato'));
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = cfg.pdfSrc;
|
||||||
|
s.onload = function () { resolve(global.pdfjsLib); };
|
||||||
|
s.onerror = function () { reject(new Error('pdf.js non caricato')); };
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImage(file) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var url = URL.createObjectURL(file);
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
|
||||||
|
img.onerror = function () { URL.revokeObjectURL(url); reject(new Error('immagine illeggibile')); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPdfPage(pdf, n) {
|
||||||
|
return pdf.getPage(n).then(function (page) {
|
||||||
|
var base = page.getViewport({ scale: 1 });
|
||||||
|
var long = Math.max(base.width, base.height);
|
||||||
|
var scale = Math.min(PDF_MAX_LONG / long, Math.max(2, PDF_MIN_LONG / long));
|
||||||
|
var vp = page.getViewport({ scale: scale });
|
||||||
|
var c = document.createElement('canvas');
|
||||||
|
c.width = Math.round(vp.width);
|
||||||
|
c.height = Math.round(vp.height);
|
||||||
|
return page.render({ canvasContext: c.getContext('2d'), viewport: vp }).promise
|
||||||
|
.then(function () { return c; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (text) e.textContent = text;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(file) {
|
||||||
|
var L = cfg.labels;
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var src = null; // Image o canvas sorgente a piena risoluzione
|
||||||
|
var rect = null; // {x,y,w,h} in px immagine
|
||||||
|
var pdfDoc = null;
|
||||||
|
var closed = false;
|
||||||
|
|
||||||
|
// ---- DOM (stile Maker: variabili CSS del tema + classi btn gia' presenti) ----
|
||||||
|
var overlay = el('div', 'fixed inset-0 z-[100] flex items-center justify-center p-3 bg-black/60');
|
||||||
|
overlay.setAttribute('role', 'dialog');
|
||||||
|
overlay.setAttribute('aria-modal', 'true');
|
||||||
|
var box = el('div', 'rounded-xl shadow-xl flex flex-col w-full max-w-4xl max-h-full');
|
||||||
|
box.style.cssText = 'background:var(--bg-primary,#fff);color:var(--text-primary,#111);border:1px solid var(--border-color,#ddd);';
|
||||||
|
var head = el('div', 'flex items-center justify-between gap-3 px-4 py-3');
|
||||||
|
head.appendChild(el('h3', 'font-semibold text-sm', L.title));
|
||||||
|
var pageWrap = el('label', 'text-xs items-center gap-2');
|
||||||
|
pageWrap.style.display = 'none';
|
||||||
|
pageWrap.appendChild(document.createTextNode(L.page + ' '));
|
||||||
|
var pageSel = el('select', 'tmf-input text-xs');
|
||||||
|
pageSel.style.width = 'auto';
|
||||||
|
pageWrap.appendChild(pageSel);
|
||||||
|
head.appendChild(pageWrap);
|
||||||
|
var body = el('div', 'px-4 flex-1 min-h-0 overflow-auto flex justify-center');
|
||||||
|
var status = el('p', 'text-xs py-6', L.loading);
|
||||||
|
body.appendChild(status);
|
||||||
|
var canvas = el('canvas');
|
||||||
|
canvas.style.cssText = 'max-width:100%;max-height:65vh;touch-action:none;cursor:crosshair;display:none;';
|
||||||
|
body.appendChild(canvas);
|
||||||
|
var foot = el('div', 'px-4 py-3 flex flex-wrap items-center justify-between gap-2');
|
||||||
|
var note = el('p', 'text-xs');
|
||||||
|
note.style.color = 'var(--text-muted,#666)';
|
||||||
|
note.textContent = L.hint + (isPdf(file) ? ' ' + L.pdfNote : '');
|
||||||
|
var btns = el('div', 'flex gap-2');
|
||||||
|
var bCancel = el('button', 'btn btn-secondary text-xs', L.cancel);
|
||||||
|
var bOrig = el('button', 'btn btn-secondary text-xs', L.original);
|
||||||
|
var bOk = el('button', 'btn btn-primary text-xs', L.confirm);
|
||||||
|
[bCancel, bOrig, bOk].forEach(function (b) { b.type = 'button'; btns.appendChild(b); });
|
||||||
|
bOk.disabled = true;
|
||||||
|
foot.appendChild(note);
|
||||||
|
foot.appendChild(btns);
|
||||||
|
box.appendChild(head); box.appendChild(body); box.appendChild(foot);
|
||||||
|
overlay.appendChild(box);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
function onKey(e) { if (e.key === 'Escape') finish(null); }
|
||||||
|
function finish(value) {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
overlay.remove();
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
bCancel.onclick = function () { finish(null); };
|
||||||
|
bOrig.onclick = function () { finish(file); };
|
||||||
|
bOk.onclick = function () {
|
||||||
|
if (!src || !rect) return;
|
||||||
|
var out = document.createElement('canvas');
|
||||||
|
out.width = Math.round(rect.w);
|
||||||
|
out.height = Math.round(rect.h);
|
||||||
|
out.getContext('2d').drawImage(src, Math.round(rect.x), Math.round(rect.y),
|
||||||
|
out.width, out.height, 0, 0, out.width, out.height);
|
||||||
|
out.toBlob(function (blob) { finish(blob || file); }, 'image/png');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- disegno ----
|
||||||
|
function sw() { return src.width || src.naturalWidth; }
|
||||||
|
function sh() { return src.height || src.naturalHeight; }
|
||||||
|
function handlePoints(r) {
|
||||||
|
var mx = r.x + r.w / 2, my = r.y + r.h / 2, ex = r.x + r.w, ey = r.y + r.h;
|
||||||
|
return [
|
||||||
|
{ n: 'nw', x: r.x, y: r.y }, { n: 'n', x: mx, y: r.y }, { n: 'ne', x: ex, y: r.y },
|
||||||
|
{ n: 'e', x: ex, y: my }, { n: 'se', x: ex, y: ey }, { n: 's', x: mx, y: ey },
|
||||||
|
{ n: 'sw', x: r.x, y: ey }, { n: 'w', x: r.x, y: my }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function draw() {
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(src, 0, 0, canvas.width, canvas.height);
|
||||||
|
var r = rect; // il canvas di visualizzazione ha la risoluzione della sorgente
|
||||||
|
// velo scuro fuori dal ritaglio (4 fasce)
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, r.y);
|
||||||
|
ctx.fillRect(0, r.y + r.h, canvas.width, canvas.height - r.y - r.h);
|
||||||
|
ctx.fillRect(0, r.y, r.x, r.h);
|
||||||
|
ctx.fillRect(r.x + r.w, r.y, canvas.width - r.x - r.w, r.h);
|
||||||
|
ctx.strokeStyle = '#3b82f6';
|
||||||
|
ctx.lineWidth = Math.max(2, canvas.width / 400);
|
||||||
|
ctx.strokeRect(r.x, r.y, r.w, r.h);
|
||||||
|
ctx.fillStyle = '#3b82f6';
|
||||||
|
var hs = Math.max(8, canvas.width / 80);
|
||||||
|
handlePoints(r).forEach(function (p) { ctx.fillRect(p.x - hs / 2, p.y - hs / 2, hs, hs); });
|
||||||
|
}
|
||||||
|
function setSource(s) {
|
||||||
|
src = s;
|
||||||
|
canvas.width = sw();
|
||||||
|
canvas.height = sh();
|
||||||
|
rect = { x: 0, y: 0, w: sw(), h: sh() }; // all'inizio: tutta l'immagine
|
||||||
|
status.style.display = 'none';
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
bOk.disabled = false;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- interazione ----
|
||||||
|
var drag = null;
|
||||||
|
function toImg(e) {
|
||||||
|
var b = canvas.getBoundingClientRect();
|
||||||
|
return { x: (e.clientX - b.left) * sw() / b.width, y: (e.clientY - b.top) * sh() / b.height,
|
||||||
|
k: sw() / b.width }; // k = px immagine per px schermo
|
||||||
|
}
|
||||||
|
function hit(p) {
|
||||||
|
var best = null, bd = HANDLE * p.k;
|
||||||
|
handlePoints(rect).forEach(function (h) {
|
||||||
|
var d = Math.max(Math.abs(h.x - p.x), Math.abs(h.y - p.y));
|
||||||
|
if (d <= bd) { bd = d; best = h.n; }
|
||||||
|
});
|
||||||
|
if (best) return best;
|
||||||
|
if (p.x > rect.x && p.x < rect.x + rect.w && p.y > rect.y && p.y < rect.y + rect.h) return 'move';
|
||||||
|
return 'new';
|
||||||
|
}
|
||||||
|
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
||||||
|
canvas.addEventListener('pointerdown', function (e) {
|
||||||
|
if (!src) return;
|
||||||
|
var p = toImg(e);
|
||||||
|
var mode = hit(p);
|
||||||
|
drag = { mode: mode, sx: p.x, sy: p.y, r0: { x: rect.x, y: rect.y, w: rect.w, h: rect.h } };
|
||||||
|
if (mode === 'new') { // nuovo rettangolo: parte dal punto di tocco
|
||||||
|
drag.r0 = { x: clamp(p.x, 0, sw()), y: clamp(p.y, 0, sh()), w: 0, h: 0 };
|
||||||
|
}
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
canvas.addEventListener('pointermove', function (e) {
|
||||||
|
if (!src) return;
|
||||||
|
var p = toImg(e);
|
||||||
|
if (!drag) {
|
||||||
|
var m = hit(p);
|
||||||
|
canvas.style.cursor = m === 'move' ? 'move' : m === 'new' ? 'crosshair'
|
||||||
|
: (m.length === 2 ? (m === 'nw' || m === 'se' ? 'nwse-resize' : 'nesw-resize')
|
||||||
|
: (m === 'n' || m === 's' ? 'ns-resize' : 'ew-resize'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dx = p.x - drag.sx, dy = p.y - drag.sy, r0 = drag.r0, W = sw(), H = sh();
|
||||||
|
if (drag.mode === 'move') {
|
||||||
|
rect.x = clamp(r0.x + dx, 0, W - r0.w);
|
||||||
|
rect.y = clamp(r0.y + dy, 0, H - r0.h);
|
||||||
|
} else {
|
||||||
|
var x1 = r0.x, y1 = r0.y, x2 = r0.x + r0.w, y2 = r0.y + r0.h, m2 = drag.mode;
|
||||||
|
if (m2 === 'new') { x2 = clamp(p.x, 0, W); y2 = clamp(p.y, 0, H); }
|
||||||
|
else {
|
||||||
|
if (m2.indexOf('w') >= 0) x1 = clamp(r0.x + dx, 0, x2 - MIN_SIDE);
|
||||||
|
if (m2.indexOf('e') >= 0) x2 = clamp(x2 + dx, x1 + MIN_SIDE, W);
|
||||||
|
if (m2.indexOf('n') >= 0) y1 = clamp(r0.y + dy, 0, y2 - MIN_SIDE);
|
||||||
|
if (m2.indexOf('s') >= 0) y2 = clamp(y2 + dy, y1 + MIN_SIDE, H);
|
||||||
|
}
|
||||||
|
rect = { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
|
||||||
|
}
|
||||||
|
bOk.disabled = rect.w < MIN_SIDE || rect.h < MIN_SIDE; // ritaglio troppo piccolo: non confermabile
|
||||||
|
draw();
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
function up() { drag = null; }
|
||||||
|
canvas.addEventListener('pointerup', up);
|
||||||
|
canvas.addEventListener('pointercancel', up);
|
||||||
|
|
||||||
|
// ---- caricamento sorgente ----
|
||||||
|
function fail(err) {
|
||||||
|
console.error('ImageCrop:', err);
|
||||||
|
status.style.display = '';
|
||||||
|
status.textContent = L.error;
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
bOk.disabled = true;
|
||||||
|
}
|
||||||
|
if (isPdf(file)) {
|
||||||
|
loadPdfJs().then(function (lib) {
|
||||||
|
if (cfg.workerSrc) lib.GlobalWorkerOptions.workerSrc = cfg.workerSrc;
|
||||||
|
return file.arrayBuffer().then(function (buf) { return lib.getDocument({ data: buf }).promise; });
|
||||||
|
}).then(function (pdf) {
|
||||||
|
pdfDoc = pdf;
|
||||||
|
if (pdf.numPages > 1) {
|
||||||
|
for (var i = 1; i <= pdf.numPages; i++) {
|
||||||
|
var o = el('option', '', String(i)); o.value = String(i); pageSel.appendChild(o);
|
||||||
|
}
|
||||||
|
pageWrap.style.display = 'flex';
|
||||||
|
}
|
||||||
|
return renderPdfPage(pdf, 1);
|
||||||
|
}).then(setSource).catch(fail);
|
||||||
|
pageSel.onchange = function () {
|
||||||
|
bOk.disabled = true;
|
||||||
|
renderPdfPage(pdfDoc, parseInt(pageSel.value, 10)).then(setSource).catch(fail);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
readImage(file).then(setSource).catch(fail);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
global.ImageCrop = { open: open, configure: configure };
|
||||||
|
})(window);
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/* Login Operatore: nome + PIN a 4 cifre (#59).
|
||||||
|
* Componente Alpine globale `pinLogin()`, usato da templates/auth/login.html.
|
||||||
|
* Contratto: GET /auth/pin-users -> [{id, display_name}];
|
||||||
|
* POST /auth/login-pin {user_id, pin} -> {ok, redirect} | {error, detail}. */
|
||||||
|
function pinLogin() {
|
||||||
|
return {
|
||||||
|
mode: 'pin', // vista Operatore di default; se l'elenco e' vuoto/fallisce passa a 'password'
|
||||||
|
users: [],
|
||||||
|
selected: null,
|
||||||
|
pin: '',
|
||||||
|
error: '',
|
||||||
|
busy: false,
|
||||||
|
|
||||||
|
init: function () {
|
||||||
|
var self = this;
|
||||||
|
fetch(window.TMF_PIN.usersUrl, { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : []; })
|
||||||
|
.then(function (list) {
|
||||||
|
self.users = Array.isArray(list) ? list : [];
|
||||||
|
if (!self.users.length) self.mode = 'password'; // elenco vuoto: form password
|
||||||
|
})
|
||||||
|
.catch(function () { self.users = []; self.mode = 'password'; });
|
||||||
|
},
|
||||||
|
select: function (u) { this.selected = u; this.pin = ''; this.error = ''; },
|
||||||
|
back: function () { this.selected = null; this.pin = ''; this.error = ''; },
|
||||||
|
del: function () { this.pin = this.pin.slice(0, -1); },
|
||||||
|
press: function (d) {
|
||||||
|
if (this.busy || this.pin.length >= 4) return;
|
||||||
|
this.pin += d;
|
||||||
|
this.error = '';
|
||||||
|
if (this.pin.length === 4) this.submit(); // invio automatico alla quarta cifra
|
||||||
|
},
|
||||||
|
submit: function () {
|
||||||
|
var self = this;
|
||||||
|
var csrf = document.querySelector('meta[name=csrf-token]');
|
||||||
|
self.busy = true;
|
||||||
|
fetch(window.TMF_PIN.loginUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRFToken': (csrf && csrf.content) || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ user_id: self.selected.id, pin: self.pin })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().catch(function () { return {}; }); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d.ok && d.redirect) { window.location.href = d.redirect; return; }
|
||||||
|
self.error = d.detail || window.TMF_PIN.msgNet; // 401 PIN errato, 423 blocco con ora
|
||||||
|
self.pin = '';
|
||||||
|
self.busy = false;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
self.error = window.TMF_PIN.msgNet;
|
||||||
|
self.pin = '';
|
||||||
|
self.busy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Vision execute - Alpine component for camera_measure / dxf_compare tasks (#46).
|
||||||
|
*
|
||||||
|
* The operator takes (or picks) an image, `acquire(blob)` sends it to the Flask
|
||||||
|
* proxy, and the result - quote table, outcome, failures, overlay - is shown on
|
||||||
|
* the image. `acquire(blob)` is the single entry point on purpose: the agent
|
||||||
|
* camera (#3) will call it with its own blob and reuse everything below.
|
||||||
|
*
|
||||||
|
* Config comes from window.__visionCfg (set by the template):
|
||||||
|
* executeUrl, imageUrlTpl (with a 0 placeholder for the result id), taskId,
|
||||||
|
* taskType, subtasks, labels.
|
||||||
|
* Run context (lot, serial, production run) is read lazily through `ctx()`,
|
||||||
|
* which the template binds to the parent task component.
|
||||||
|
*/
|
||||||
|
function visionExecute() {
|
||||||
|
// Overlay colour per role (VisionSuite roles: reference, roi, found).
|
||||||
|
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
busy: false,
|
||||||
|
error: '',
|
||||||
|
result: null, // last execute response
|
||||||
|
imageSrc: '', // object URL of the acquired image (shown under the overlay)
|
||||||
|
ctx: () => ({}), // replaced by the template: {lot, serial, runId}
|
||||||
|
|
||||||
|
// Entry point shared with the future agent camera.
|
||||||
|
async acquire(blob) {
|
||||||
|
if (!blob || this.busy) return;
|
||||||
|
const cfg = window.__visionCfg || {};
|
||||||
|
this.busy = true;
|
||||||
|
this.error = '';
|
||||||
|
this.result = null;
|
||||||
|
// Show the picked image at once: the overlay is drawn on top when the result arrives.
|
||||||
|
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||||
|
this.imageSrc = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const c = this.ctx() || {};
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('task_id', cfg.taskId);
|
||||||
|
fd.append('task_type', cfg.taskType || '');
|
||||||
|
fd.append('image', blob, blob.name || 'capture.jpg');
|
||||||
|
fd.append('lot_number', c.lot || '');
|
||||||
|
fd.append('serial_number', c.serial || '');
|
||||||
|
if (c.runId) fd.append('production_run_id', c.runId);
|
||||||
|
const csrf = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||||
|
const resp = await fetch(cfg.executeUrl, {
|
||||||
|
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd,
|
||||||
|
});
|
||||||
|
const body = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok || body.error) {
|
||||||
|
this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.result = body;
|
||||||
|
// Draw once Alpine has rendered the canvas.
|
||||||
|
this.$nextTick(() => this.drawOverlay());
|
||||||
|
} catch (e) {
|
||||||
|
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Input change: take the file and allow picking the same one again.
|
||||||
|
onPick(ev) {
|
||||||
|
const f = ev.target.files && ev.target.files[0];
|
||||||
|
ev.target.value = '';
|
||||||
|
if (f) this.acquire(f);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Image shown: the saved one from the server when available, else the local one.
|
||||||
|
get shownImage() {
|
||||||
|
const cfg = window.__visionCfg || {};
|
||||||
|
if (this.result && this.result.vision_result_id && cfg.imageUrlTpl) {
|
||||||
|
return cfg.imageUrlTpl.replace('/0/', '/' + this.result.vision_result_id + '/');
|
||||||
|
}
|
||||||
|
return this.imageSrc;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Quote rows: server measurements joined with the subtask names.
|
||||||
|
get rows() {
|
||||||
|
const cfg = window.__visionCfg || {};
|
||||||
|
const subs = cfg.subtasks || [];
|
||||||
|
return ((this.result && this.result.measurements) || []).map((m) => {
|
||||||
|
const st = subs.find((s) => s.id === m.subtask_id) || {};
|
||||||
|
return {
|
||||||
|
label: (st.marker_number ? st.marker_number + '. ' : '') + (st.measurement_type || st.name || '#' + m.subtask_id),
|
||||||
|
value: m.value,
|
||||||
|
unit: st.unit || 'mm',
|
||||||
|
status: m.pass_fail,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
statusClass(s) {
|
||||||
|
// Same palette as the measurement markers of the manual flow.
|
||||||
|
if (s === 'pass') return 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40';
|
||||||
|
if (s === 'fail') return 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40';
|
||||||
|
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
|
||||||
|
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Canvas is sized to the natural image, so overlay coordinates (image px)
|
||||||
|
// are used as they are and CSS scales the whole thing to the displayed size.
|
||||||
|
drawOverlay() {
|
||||||
|
const img = this.$refs.img, cv = this.$refs.overlay;
|
||||||
|
if (!img || !cv || !img.naturalWidth) return;
|
||||||
|
cv.width = img.naturalWidth;
|
||||||
|
cv.height = img.naturalHeight;
|
||||||
|
const g = cv.getContext('2d');
|
||||||
|
g.clearRect(0, 0, cv.width, cv.height);
|
||||||
|
g.lineWidth = Math.max(2, cv.width / 400);
|
||||||
|
g.lineJoin = 'round';
|
||||||
|
for (const item of ((this.result && this.result.overlay) || [])) {
|
||||||
|
const rows = item.rows || [], cols = item.cols || [];
|
||||||
|
if (!rows.length || rows.length !== cols.length) continue;
|
||||||
|
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(cols[0], rows[0]);
|
||||||
|
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Ricetta visione del Maker (#46): componente Alpine per i task camera_measure / dxf_compare.
|
||||||
|
// Carica la ricetta VisionSuite v2 + i file allegati verso /maker/api/vision/tasks/<id>/recipe,
|
||||||
|
// mostra cartella, file e uscite numeriche; notifica le uscite al task editor con un evento.
|
||||||
|
// Uso: x-data="visionRecipe(task.id, csrfToken, msgs)". Dipende da Alpine.js.
|
||||||
|
function visionRecipe(taskId, csrf, msgs) {
|
||||||
|
return {
|
||||||
|
taskId: taskId,
|
||||||
|
recipeDir: null,
|
||||||
|
files: [],
|
||||||
|
outputs: [],
|
||||||
|
errors: [], // errori 422 elencati in chiaro
|
||||||
|
loading: false,
|
||||||
|
busy: false,
|
||||||
|
recipeFile: null,
|
||||||
|
attachments: [],
|
||||||
|
msgs: msgs || {},
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
// All'apertura: ricetta corrente (GET); il 404/errore non blocca l'editor
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`);
|
||||||
|
if (resp.ok) this._apply(await resp.json());
|
||||||
|
} catch (e) { console.error('visionRecipe init', e); }
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Aggiorna lo stato e avvisa il task editor (select vision_output delle quote)
|
||||||
|
_apply(data) {
|
||||||
|
this.recipeDir = data.recipe_dir || null;
|
||||||
|
this.files = data.files || [];
|
||||||
|
this.outputs = data.outputs || [];
|
||||||
|
window.dispatchEvent(new CustomEvent('vision-outputs', {
|
||||||
|
detail: { taskId: this.taskId, outputs: this.outputs }
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Il 422 porta `errors` (lista) oppure `detail` (stringa o lista di voci)
|
||||||
|
_errorsFrom(data) {
|
||||||
|
if (Array.isArray(data.errors) && data.errors.length) return data.errors.map(String);
|
||||||
|
const d = data.detail;
|
||||||
|
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x))));
|
||||||
|
if (d && typeof d === 'object') {
|
||||||
|
if (Array.isArray(d.errors)) return d.errors.map(String);
|
||||||
|
return [JSON.stringify(d)];
|
||||||
|
}
|
||||||
|
return [d || this.msgs.genericError || 'Error'];
|
||||||
|
},
|
||||||
|
|
||||||
|
async upload() {
|
||||||
|
if (!this.recipeFile) return;
|
||||||
|
this.busy = true;
|
||||||
|
this.errors = [];
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('recipe', this.recipeFile);
|
||||||
|
for (const f of this.attachments) fd.append('files', f);
|
||||||
|
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': typeof csrf === 'function' ? csrf() : csrf },
|
||||||
|
body: fd
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || data.error) {
|
||||||
|
this.errors = this._errorsFrom(data);
|
||||||
|
} else {
|
||||||
|
this._apply(data);
|
||||||
|
this.recipeFile = null;
|
||||||
|
this.attachments = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.errors = [this.msgs.genericError || String(e)];
|
||||||
|
}
|
||||||
|
this.busy = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
onRecipe(ev) { this.recipeFile = ev.target.files[0] || null; },
|
||||||
|
onAttachments(ev) { this.attachments = Array.from(ev.target.files || []); }
|
||||||
|
};
|
||||||
|
}
|
||||||
+55
-219
@@ -1,18 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{# Pannello utenti: incluso nella tab «Utenti» di Impostazioni (#50) #}
|
||||||
|
|
||||||
{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<script>window.__users = {{ users|tojson }};</script>
|
<script>window.__users = {{ users|tojson }};</script>
|
||||||
|
|
||||||
<div class="tmf-page"
|
<div x-data="userManagement(window.__users)">
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
||||||
@@ -102,6 +91,12 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- #51: reset password dall'admin -->
|
||||||
|
<button @click="openReset(user)" class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="{{ _('Reset password')|tojson_attr }}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
|
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
|
||||||
:class="user.active
|
:class="user.active
|
||||||
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||||
@@ -332,210 +327,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- #51: Modal reset password (la password attuale non si mostra mai) -->
|
||||||
|
<div x-show="showReset" x-cloak
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
@keydown.escape.window="closeReset()">
|
||||||
|
<div class="absolute inset-0 bg-black/50" @click="closeReset()"></div>
|
||||||
|
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||||
|
{{ _('Reset password') }}: <span x-text="resetUser?.username"></span>
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||||
|
{{ _('Le sessioni aperte dell\'utente verranno chiuse e dovrà accedere con la nuova password.') }}
|
||||||
|
</p>
|
||||||
|
<template x-if="!resetDone">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuova password (minimo 8 caratteri)') }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" x-model="resetPw" autocomplete="off"
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
|
||||||
|
<button @click="generateResetPw()" type="button"
|
||||||
|
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
|
||||||
|
{{ _('Genera') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template x-if="resetError">
|
||||||
|
<p class="mt-2 text-sm text-red-600" x-text="resetError"></p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="resetDone">
|
||||||
|
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
|
||||||
|
<p class="text-sm text-emerald-700 dark:text-emerald-300">{{ _('Password cambiata. Annotala ora: non verrà mostrata di nuovo.') }}</p>
|
||||||
|
<p class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="resetPw"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex justify-end gap-3 mt-5">
|
||||||
|
<button @click="closeReset()"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
|
||||||
|
<span x-text="resetDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
|
||||||
|
</button>
|
||||||
|
<button x-show="!resetDone" @click="confirmReset()" :disabled="saving"
|
||||||
|
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
|
||||||
|
{{ _('Imposta password') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
function userManagement(initialUsers) {
|
|
||||||
return {
|
|
||||||
users: initialUsers || [],
|
|
||||||
search: '',
|
|
||||||
showModal: false,
|
|
||||||
showToggleConfirm: false,
|
|
||||||
isEditing: false,
|
|
||||||
editingUserId: null,
|
|
||||||
toggleUser: null,
|
|
||||||
saving: false,
|
|
||||||
errorMsg: '',
|
|
||||||
form: {
|
|
||||||
username: '',
|
|
||||||
display_name: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
roles: [],
|
|
||||||
is_admin: false,
|
|
||||||
language_pref: 'it',
|
|
||||||
theme_pref: 'light'
|
|
||||||
},
|
|
||||||
|
|
||||||
get csrfToken() {
|
|
||||||
const meta = document.querySelector('meta[name=csrf-token]');
|
|
||||||
return meta ? meta.content : '';
|
|
||||||
},
|
|
||||||
|
|
||||||
get filteredUsers() {
|
|
||||||
if (!this.search.trim()) return this.users;
|
|
||||||
const q = this.search.toLowerCase();
|
|
||||||
return this.users.filter(u =>
|
|
||||||
u.username.toLowerCase().includes(q) ||
|
|
||||||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
|
|
||||||
(u.email && u.email.toLowerCase().includes(q)) ||
|
|
||||||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
openCreateModal() {
|
|
||||||
this.isEditing = false;
|
|
||||||
this.editingUserId = null;
|
|
||||||
this.errorMsg = '';
|
|
||||||
this.form = {
|
|
||||||
username: '',
|
|
||||||
display_name: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
roles: [],
|
|
||||||
is_admin: false,
|
|
||||||
language_pref: 'it',
|
|
||||||
theme_pref: 'light'
|
|
||||||
};
|
|
||||||
this.showModal = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
openEditModal(user) {
|
|
||||||
this.isEditing = true;
|
|
||||||
this.editingUserId = user.id;
|
|
||||||
this.errorMsg = '';
|
|
||||||
this.form = {
|
|
||||||
username: user.username,
|
|
||||||
display_name: user.display_name || '',
|
|
||||||
email: user.email || '',
|
|
||||||
password: '',
|
|
||||||
roles: [...(user.roles || [])],
|
|
||||||
is_admin: user.is_admin || false,
|
|
||||||
language_pref: user.language_pref || 'it',
|
|
||||||
theme_pref: user.theme_pref || 'light'
|
|
||||||
};
|
|
||||||
this.showModal = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
closeModal() {
|
|
||||||
this.showModal = false;
|
|
||||||
this.errorMsg = '';
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveUser() {
|
|
||||||
this.saving = true;
|
|
||||||
this.errorMsg = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (this.isEditing) {
|
|
||||||
// Update user
|
|
||||||
const data = {
|
|
||||||
display_name: this.form.display_name,
|
|
||||||
email: this.form.email || null,
|
|
||||||
roles: this.form.roles,
|
|
||||||
is_admin: this.form.is_admin,
|
|
||||||
language_pref: this.form.language_pref,
|
|
||||||
theme_pref: this.form.theme_pref
|
|
||||||
};
|
|
||||||
|
|
||||||
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
this.errorMsg = result.detail || 'Errore nel salvataggio';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local array
|
|
||||||
const idx = this.users.findIndex(u => u.id === this.editingUserId);
|
|
||||||
if (idx >= 0) this.users[idx] = result;
|
|
||||||
|
|
||||||
// Change password if provided
|
|
||||||
if (this.form.password) {
|
|
||||||
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
|
||||||
body: JSON.stringify({ password: this.form.password })
|
|
||||||
});
|
|
||||||
if (!pwResp.ok) {
|
|
||||||
const pwResult = await pwResp.json();
|
|
||||||
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Create user
|
|
||||||
if (!this.form.username || !this.form.display_name || !this.form.password) {
|
|
||||||
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
username: this.form.username,
|
|
||||||
password: this.form.password,
|
|
||||||
display_name: this.form.display_name,
|
|
||||||
email: this.form.email || null,
|
|
||||||
roles: this.form.roles,
|
|
||||||
is_admin: this.form.is_admin,
|
|
||||||
language_pref: this.form.language_pref,
|
|
||||||
theme_pref: this.form.theme_pref
|
|
||||||
};
|
|
||||||
|
|
||||||
const resp = await fetch('/admin/api/users', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
this.errorMsg = result.detail || 'Errore nella creazione';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.users.push(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.closeModal();
|
|
||||||
} catch (e) {
|
|
||||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
|
||||||
} finally {
|
|
||||||
this.saving = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleActive(user) {
|
|
||||||
this.toggleUser = user;
|
|
||||||
this.showToggleConfirm = true;
|
|
||||||
},
|
|
||||||
|
|
||||||
async confirmToggle() {
|
|
||||||
if (!this.toggleUser) return;
|
|
||||||
this.saving = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newActive = !this.toggleUser.active;
|
|
||||||
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
|
||||||
body: JSON.stringify({ active: newActive })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resp.json();
|
|
||||||
if (!resp.ok) {
|
|
||||||
alert(result.detail || 'Errore');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update local array
|
|
||||||
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
|
|
||||||
if (idx >= 0) this.users[idx] = result;
|
|
||||||
|
|
||||||
this.showToggleConfirm = false;
|
|
||||||
this.toggleUser = null;
|
|
||||||
} catch (e) {
|
|
||||||
alert('{{ _("Errore di connessione al server") }}');
|
|
||||||
} finally {
|
|
||||||
this.saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<script>
|
||||||
|
function userManagement(initialUsers) {
|
||||||
|
return {
|
||||||
|
users: initialUsers || [],
|
||||||
|
search: '',
|
||||||
|
showModal: false,
|
||||||
|
showToggleConfirm: false,
|
||||||
|
// #51: stato del reset password
|
||||||
|
showReset: false,
|
||||||
|
resetUser: null,
|
||||||
|
resetPw: '',
|
||||||
|
resetDone: false,
|
||||||
|
resetError: '',
|
||||||
|
isEditing: false,
|
||||||
|
editingUserId: null,
|
||||||
|
toggleUser: null,
|
||||||
|
saving: false,
|
||||||
|
errorMsg: '',
|
||||||
|
form: {
|
||||||
|
username: '',
|
||||||
|
display_name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
roles: [],
|
||||||
|
is_admin: false,
|
||||||
|
language_pref: 'it',
|
||||||
|
theme_pref: 'light'
|
||||||
|
},
|
||||||
|
|
||||||
|
get csrfToken() {
|
||||||
|
const meta = document.querySelector('meta[name=csrf-token]');
|
||||||
|
return meta ? meta.content : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
get filteredUsers() {
|
||||||
|
if (!this.search.trim()) return this.users;
|
||||||
|
const q = this.search.toLowerCase();
|
||||||
|
return this.users.filter(u =>
|
||||||
|
u.username.toLowerCase().includes(q) ||
|
||||||
|
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
|
||||||
|
(u.email && u.email.toLowerCase().includes(q)) ||
|
||||||
|
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
openCreateModal() {
|
||||||
|
this.isEditing = false;
|
||||||
|
this.editingUserId = null;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.form = {
|
||||||
|
username: '',
|
||||||
|
display_name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
roles: [],
|
||||||
|
is_admin: false,
|
||||||
|
language_pref: 'it',
|
||||||
|
theme_pref: 'light'
|
||||||
|
};
|
||||||
|
this.showModal = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
openEditModal(user) {
|
||||||
|
this.isEditing = true;
|
||||||
|
this.editingUserId = user.id;
|
||||||
|
this.errorMsg = '';
|
||||||
|
this.form = {
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name || '',
|
||||||
|
email: user.email || '',
|
||||||
|
password: '',
|
||||||
|
roles: [...(user.roles || [])],
|
||||||
|
is_admin: user.is_admin || false,
|
||||||
|
language_pref: user.language_pref || 'it',
|
||||||
|
theme_pref: user.theme_pref || 'light'
|
||||||
|
};
|
||||||
|
this.showModal = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeModal() {
|
||||||
|
this.showModal = false;
|
||||||
|
this.errorMsg = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveUser() {
|
||||||
|
this.saving = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.isEditing) {
|
||||||
|
// Update user
|
||||||
|
const data = {
|
||||||
|
display_name: this.form.display_name,
|
||||||
|
email: this.form.email || null,
|
||||||
|
roles: this.form.roles,
|
||||||
|
is_admin: this.form.is_admin,
|
||||||
|
language_pref: this.form.language_pref,
|
||||||
|
theme_pref: this.form.theme_pref
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
this.errorMsg = result.detail || 'Errore nel salvataggio';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local array
|
||||||
|
const idx = this.users.findIndex(u => u.id === this.editingUserId);
|
||||||
|
if (idx >= 0) this.users[idx] = result;
|
||||||
|
|
||||||
|
// Change password if provided
|
||||||
|
if (this.form.password) {
|
||||||
|
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||||
|
body: JSON.stringify({ password: this.form.password })
|
||||||
|
});
|
||||||
|
if (!pwResp.ok) {
|
||||||
|
const pwResult = await pwResp.json();
|
||||||
|
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create user
|
||||||
|
if (!this.form.username || !this.form.display_name || !this.form.password) {
|
||||||
|
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
username: this.form.username,
|
||||||
|
password: this.form.password,
|
||||||
|
display_name: this.form.display_name,
|
||||||
|
email: this.form.email || null,
|
||||||
|
roles: this.form.roles,
|
||||||
|
is_admin: this.form.is_admin,
|
||||||
|
language_pref: this.form.language_pref,
|
||||||
|
theme_pref: this.form.theme_pref
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await fetch('/admin/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
this.errorMsg = result.detail || 'Errore nella creazione';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.users.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// #51: reset password dall'admin, usa PUT /admin/api/users/<id>/password
|
||||||
|
openReset(user) {
|
||||||
|
this.resetUser = user;
|
||||||
|
this.resetPw = '';
|
||||||
|
this.resetDone = false;
|
||||||
|
this.resetError = '';
|
||||||
|
this.showReset = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeReset() {
|
||||||
|
this.showReset = false;
|
||||||
|
this.resetPw = ''; // la password generata non resta in memoria
|
||||||
|
this.resetDone = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
generateResetPw() {
|
||||||
|
// 12 caratteri senza simboli ambigui (0/O, 1/l/I), da crypto random
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||||
|
const buf = new Uint32Array(12);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
this.resetPw = Array.from(buf, n => chars[n % chars.length]).join('');
|
||||||
|
this.resetError = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmReset() {
|
||||||
|
if (!this.resetPw || this.resetPw.length < 8) {
|
||||||
|
this.resetError = {{ _('La password deve avere almeno 8 caratteri')|tojson }};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.saving = true;
|
||||||
|
this.resetError = '';
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/admin/api/users/${this.resetUser.id}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||||
|
body: JSON.stringify({ password: this.resetPw })
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
this.resetError = result.detail || {{ _('Errore nel cambio password')|tojson }};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.resetDone = true;
|
||||||
|
} catch (e) {
|
||||||
|
this.resetError = {{ _('Errore di connessione al server')|tojson }};
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleActive(user) {
|
||||||
|
this.toggleUser = user;
|
||||||
|
this.showToggleConfirm = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async confirmToggle() {
|
||||||
|
if (!this.toggleUser) return;
|
||||||
|
this.saving = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newActive = !this.toggleUser.active;
|
||||||
|
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||||
|
body: JSON.stringify({ active: newActive })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(result.detail || 'Errore');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local array
|
||||||
|
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
|
||||||
|
if (idx >= 0) this.users[idx] = result;
|
||||||
|
|
||||||
|
this.showToggleConfirm = false;
|
||||||
|
this.toggleUser = null;
|
||||||
|
} catch (e) {
|
||||||
|
alert('{{ _("Errore di connessione al server") }}');
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -3,17 +3,31 @@
|
|||||||
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="tmf-page tmf-page-narrow"
|
<div class="tmf-page" x-data="{ tab: {{ active_tab|tojson_attr }} }">
|
||||||
x-data="systemSettings()">
|
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-6">
|
<div class="mb-4">
|
||||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
|
<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>
|
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- #50: tab. L'intera pagina e' gia' solo per admin (admin_required) -->
|
||||||
|
<div class="flex gap-1 mb-6 border-b border-[var(--border-color)]">
|
||||||
|
<button type="button" @click="tab = 'generale'"
|
||||||
|
:class="tab === 'generale' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
|
||||||
|
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Generale') }}</button>
|
||||||
|
<button type="button" @click="tab = 'utenti'"
|
||||||
|
:class="tab === 'utenti' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
|
||||||
|
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Utenti') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'utenti'" x-cloak>
|
||||||
|
{% include "admin/_users_panel.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="tab === 'generale'" class="max-w-4xl mx-auto" x-data="systemSettings()">
|
||||||
<!-- Settings Card -->
|
<!-- Settings Card -->
|
||||||
<div class="tmf-card">
|
<div class="tmf-card mb-6">
|
||||||
<div class="tmf-card-header flex items-center gap-2">
|
<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">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
@@ -43,6 +57,57 @@
|
|||||||
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
|
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="tmf-card-footer flex items-center justify-between">
|
<div class="tmf-card-footer flex items-center justify-between">
|
||||||
@@ -68,18 +133,48 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div><!-- /tab generale -->
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<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() {
|
function systemSettings() {
|
||||||
return {
|
return {
|
||||||
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
|
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,
|
saving: false,
|
||||||
saved: false,
|
saved: false,
|
||||||
errorMessage: '',
|
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() {
|
async save() {
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.saved = false;
|
this.saved = false;
|
||||||
@@ -87,7 +182,13 @@ function systemSettings() {
|
|||||||
|
|
||||||
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
||||||
var payload = {
|
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 {
|
try {
|
||||||
@@ -111,4 +212,5 @@ function systemSettings() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
{% include "admin/_users_script.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -26,8 +26,50 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<div x-data="pinLogin()" x-init="init()">
|
||||||
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
<!-- 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() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<!-- Username -->
|
<!-- Username -->
|
||||||
<div>
|
<div>
|
||||||
@@ -98,6 +140,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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 -->
|
<!-- Help Text -->
|
||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<p class="text-xs text-[var(--text-muted)]">
|
<p class="text-xs text-[var(--text-muted)]">
|
||||||
@@ -115,3 +162,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
/* Configurazione e testi per pin-pad.js (#59) */
|
||||||
|
window.TMF_PIN = {
|
||||||
|
usersUrl: {{ url_for('auth.pin_users')|tojson }},
|
||||||
|
loginUrl: {{ url_for('auth.login_pin')|tojson }},
|
||||||
|
msgNet: {{ _('Errore di connessione al server')|tojson }}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/pin-pad.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
|
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.get('is_admin') %}
|
{% 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.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) }}
|
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -503,6 +503,32 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% 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>
|
<script>
|
||||||
function recipeEditor() {
|
function recipeEditor() {
|
||||||
return {
|
return {
|
||||||
@@ -526,7 +552,8 @@ function recipeEditor() {
|
|||||||
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
|
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
|
||||||
|
|
||||||
// ---- Measurement interval ----
|
// ---- 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) ----
|
// ---- Rules of measurement (points 8 and 9) ----
|
||||||
// A new recipe forbids typing: the caliper is the instrument, and permission
|
// A new recipe forbids typing: the caliper is the instrument, and permission
|
||||||
@@ -672,6 +699,10 @@ function recipeEditor() {
|
|||||||
return;
|
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.uploadingFile = true;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,51 @@
|
|||||||
filter: 'all',
|
filter: 'all',
|
||||||
deleteModal: false,
|
deleteModal: false,
|
||||||
deleteTarget: null,
|
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() {
|
get filteredRecipes() {
|
||||||
let filtered = this.recipes;
|
let filtered = this.recipes;
|
||||||
@@ -265,6 +310,15 @@
|
|||||||
{{ _('Versioni') }}
|
{{ _('Versioni') }}
|
||||||
</a>
|
</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 -->
|
<!-- Spacer -->
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
@@ -318,6 +372,42 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</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 -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<div x-show="deleteModal"
|
<div x-show="deleteModal"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
<option value="note">{{ _('Nota') }}</option>
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||||
|
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">
|
||||||
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
|
||||||
@@ -460,6 +462,55 @@
|
|||||||
|
|
||||||
<div class="tmf-card-body space-y-4">
|
<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 ========== -->
|
<!-- ========== Task File Upload / Preview ========== -->
|
||||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
<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">
|
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||||
@@ -543,6 +594,8 @@
|
|||||||
<option value="note">{{ _('Nota') }}</option>
|
<option value="note">{{ _('Nota') }}</option>
|
||||||
<option value="measure">{{ _('Misura') }}</option>
|
<option value="measure">{{ _('Misura') }}</option>
|
||||||
<option value="drawing">{{ _('Disegno') }}</option>
|
<option value="drawing">{{ _('Disegno') }}</option>
|
||||||
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
||||||
|
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -699,6 +752,18 @@
|
|||||||
class="tmf-input"
|
class="tmf-input"
|
||||||
placeholder="{{ _('Es. Diametro foro principale') }}">
|
placeholder="{{ _('Es. Diametro foro principale') }}">
|
||||||
</div>
|
</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 -->
|
<!-- Measurement Type -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
||||||
@@ -1093,9 +1158,36 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
{# 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/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 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>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/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>
|
<script>
|
||||||
// Force reload when page is restored from browser bfcache (back/forward navigation).
|
// Force reload when page is restored from browser bfcache (back/forward navigation).
|
||||||
// This ensures fresh data is displayed after editing annotations in task_drawing.
|
// This ensures fresh data is displayed after editing annotations in task_drawing.
|
||||||
@@ -1129,8 +1221,11 @@ function taskEditor() {
|
|||||||
addingSubtaskForTask: null,
|
addingSubtaskForTask: null,
|
||||||
newSubtask: {
|
newSubtask: {
|
||||||
marker_number: 1, description: '', measurement_type: '',
|
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: {},
|
||||||
|
|
||||||
// ---- Delete modals ----
|
// ---- Delete modals ----
|
||||||
deleteTaskModal: false,
|
deleteTaskModal: false,
|
||||||
@@ -1153,6 +1248,10 @@ function taskEditor() {
|
|||||||
// Init
|
// Init
|
||||||
// ============================================================
|
// ============================================================
|
||||||
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
|
// Sort tasks by order_index on load
|
||||||
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
||||||
|
|
||||||
@@ -1610,7 +1709,8 @@ function taskEditor() {
|
|||||||
uwl: null,
|
uwl: null,
|
||||||
lwl: null,
|
lwl: null,
|
||||||
ltl: null,
|
ltl: null,
|
||||||
unit: 'mm'
|
unit: 'mm',
|
||||||
|
vision_output: ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1634,7 +1734,8 @@ function taskEditor() {
|
|||||||
uwl: this.newSubtask.uwl,
|
uwl: this.newSubtask.uwl,
|
||||||
lwl: this.newSubtask.lwl,
|
lwl: this.newSubtask.lwl,
|
||||||
ltl: this.newSubtask.ltl,
|
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 {
|
try {
|
||||||
@@ -1687,7 +1788,8 @@ function taskEditor() {
|
|||||||
uwl: subtask.uwl,
|
uwl: subtask.uwl,
|
||||||
lwl: subtask.lwl,
|
lwl: subtask.lwl,
|
||||||
ltl: subtask.ltl,
|
ltl: subtask.ltl,
|
||||||
unit: subtask.unit || 'mm'
|
unit: subtask.unit || 'mm',
|
||||||
|
vision_output: subtask.vision_output || ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1711,7 +1813,8 @@ function taskEditor() {
|
|||||||
uwl: this.newSubtask.uwl,
|
uwl: this.newSubtask.uwl,
|
||||||
lwl: this.newSubtask.lwl,
|
lwl: this.newSubtask.lwl,
|
||||||
ltl: this.newSubtask.ltl,
|
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 {
|
try {
|
||||||
@@ -1810,6 +1913,10 @@ function taskEditor() {
|
|||||||
return;
|
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.saving = true;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,15 @@
|
|||||||
barcodeInput: '',
|
barcodeInput: '',
|
||||||
barcodeLoading: false,
|
barcodeLoading: false,
|
||||||
barcodeError: '',
|
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() {
|
get filteredRecipes() {
|
||||||
if (!this.search) return this.recipes;
|
if (!this.search) return this.recipes;
|
||||||
const q = this.search.toLowerCase();
|
const q = this.search.toLowerCase();
|
||||||
@@ -62,9 +71,12 @@
|
|||||||
// The scanner is a way in like any other: a recipe that demands a lot
|
// 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
|
// demands it here too, or the rule would last exactly as long as it
|
||||||
// took someone to reach for the barcode reader.
|
// took someone to reach for the barcode reader.
|
||||||
const missing = this.missingTraceability(data);
|
// #63/#64: se la ricetta chiede lotto/seriale mancanti, la si sceglie e
|
||||||
if (missing.length) {
|
// compare il pannello con i soli campi richiesti.
|
||||||
this.barcodeError = {{ _("Compila prima:")|tojson_attr }} + ' ' + missing.join(', ');
|
if (this.missingTraceability(data).length) {
|
||||||
|
this.barcodeModal = false;
|
||||||
|
this.barcodeInput = '';
|
||||||
|
this.choose(data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.barcodeModal = false;
|
this.barcodeModal = false;
|
||||||
@@ -149,50 +161,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Results Count -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<p class="text-sm text-[var(--text-secondary)]">
|
<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)]
|
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
|
||||||
rounded-b-xl">
|
rounded-b-xl">
|
||||||
|
|
||||||
{# What this recipe insists on before anyone measures. Said on the card
|
{# #63/#64: una ricetta con lotto/seriale obbligatori si sceglie prima
|
||||||
rather than after the fact: a measurement taken without its lot cannot
|
(il pannello compare sotto la griglia); le altre partono subito. #}
|
||||||
be traced back, and finding that out later is finding it out too late. #}
|
<template x-if="needsTrace(recipe)">
|
||||||
<template x-if="missingTraceability(recipe).length">
|
<button type="button" @click="choose(recipe)"
|
||||||
<p class="mb-2 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300">
|
:class="selected && selected.id === recipe.id ? 'ring-2 ring-primary' : ''"
|
||||||
<svg class="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
class="btn btn-primary w-full justify-center text-sm font-semibold">
|
||||||
<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"/>
|
{{ _('Seleziona') }}
|
||||||
</svg>
|
</button>
|
||||||
<span x-text="{{ _('Compila prima:')|tojson_attr }} + ' ' + missingTraceability(recipe).join(', ')"></span>
|
</template>
|
||||||
</p>
|
<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>
|
</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
|
{# 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. #}
|
task left unfinished. Deliberately the quiet one of the two. #}
|
||||||
@@ -306,6 +269,41 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</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 -->
|
<!-- Empty State -->
|
||||||
<div x-show="filteredRecipes.length === 0" x-cloak
|
<div x-show="filteredRecipes.length === 0" x-cloak
|
||||||
class="text-center py-16">
|
class="text-center py-16">
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
|
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
|
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
|
||||||
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
|
{{ m.measured_at|local_dt }}
|
||||||
</td>
|
</td>
|
||||||
<td style="color: var(--text-secondary);" class="text-sm">
|
<td style="color: var(--text-secondary);" class="text-sm">
|
||||||
{{ m.task_info.title if m.task_info else '-' }}
|
{{ m.task_info.title if m.task_info else '-' }}
|
||||||
|
|||||||
@@ -115,20 +115,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ _('Lista task') }}
|
{{ _('Lista task') }}
|
||||||
</a>
|
</a>
|
||||||
<button @click="goToNextTask()"
|
{# #60: un solo «Completato», quello grande in fondo (touch); qui resta solo la lista. #}
|
||||||
: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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Fermo linea + Fine produzione (measurement tasks only) #}
|
{# Fermo linea + Fine produzione (measurement tasks only) #}
|
||||||
<template x-if="isMeasureTask">
|
<template x-if="isMeasureTask && productionRun">
|
||||||
|
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
|
||||||
<div class="shrink-0 flex items-center gap-1.5">
|
<div class="shrink-0 flex items-center gap-1.5">
|
||||||
{# While the line is stopped this becomes the way to restart it, so the
|
{# While the line is stopped this becomes the way to restart it, so the
|
||||||
capoturno never has to hunt for a second control. #}
|
capoturno never has to hunt for a second control. #}
|
||||||
@@ -191,7 +183,8 @@
|
|||||||
{# ================================================================
|
{# ================================================================
|
||||||
MAIN — 3-column layout
|
MAIN — 3-column layout
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
<div class="flex-1 flex overflow-hidden">
|
{# #46: camera_measure / dxf_compare get the acquisition panel below instead of the manual layout #}
|
||||||
|
<div class="flex-1 flex overflow-hidden" x-show="!isVisionTask">
|
||||||
|
|
||||||
{# ──────────────────────────────────────────────
|
{# ──────────────────────────────────────────────
|
||||||
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
|
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
|
||||||
@@ -451,6 +444,63 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
VISION PANEL (#46) - acquire an image, run the recipe server-side, show the
|
||||||
|
quotes and the overlay. acquire(blob) is the entry point the agent camera
|
||||||
|
(#3) will reuse.
|
||||||
|
================================================================ #}
|
||||||
|
<div x-show="isVisionTask" x-cloak
|
||||||
|
x-data="visionExecute()"
|
||||||
|
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
|
||||||
|
class="flex-1 overflow-auto p-3 flex flex-col lg:flex-row gap-3">
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
||||||
|
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
||||||
|
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
|
||||||
|
<input type="file" accept="image/*" capture="environment" class="hidden"
|
||||||
|
@change="onPick($event)" :disabled="busy">
|
||||||
|
</label>
|
||||||
|
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
|
||||||
|
|
||||||
|
<div x-show="shownImage" class="relative border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
||||||
|
<img x-ref="img" :src="shownImage" @load="drawOverlay()" alt="" class="block w-full h-auto">
|
||||||
|
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||||
|
</div>
|
||||||
|
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
||||||
|
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:w-80 shrink-0 flex flex-col gap-2" x-show="result">
|
||||||
|
<div class="text-sm font-bold text-[var(--text-primary)]">
|
||||||
|
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="w-full text-sm" x-show="rows.length">
|
||||||
|
<template x-for="r in rows" :key="r.label">
|
||||||
|
<tr>
|
||||||
|
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
|
||||||
|
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
|
||||||
|
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div x-show="result && result.failures && result.failures.length"
|
||||||
|
class="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-xs">
|
||||||
|
<div class="font-bold mb-1">{{ _('Strumenti falliti') }}</div>
|
||||||
|
<template x-for="f in ((result && result.failures) || [])" :key="f.tool_id">
|
||||||
|
<div><span class="font-mono" x-text="f.tool_name || f.tool_id"></span>: <span x-text="f.error"></span></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ================================================================
|
{# ================================================================
|
||||||
MEASUREMENT TIMER BANNER
|
MEASUREMENT TIMER BANNER
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
@@ -698,10 +748,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!isMeasureTask || subtasks.length === 0">
|
<template x-if="!isMeasureTask || subtasks.length === 0">
|
||||||
|
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
|
||||||
<button @click="goToNextTask()"
|
<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">
|
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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ _('Completato') }}
|
{{ _('Completato') }}
|
||||||
@@ -787,6 +839,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex gap-2">
|
||||||
<button @click="goToSummary()"
|
<button @click="goToSummary()"
|
||||||
class="btn btn-secondary flex-1 justify-center gap-2">
|
class="btn btn-secondary flex-1 justify-center gap-2">
|
||||||
@@ -865,7 +921,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
||||||
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
||||||
@@ -889,13 +993,13 @@
|
|||||||
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
|
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="submitSupervisor()"
|
<button @click="submitSupervisor()"
|
||||||
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
|
:disabled="!supervisorCanSubmit || supervisorValidating"
|
||||||
class="btn btn-primary flex-1 text-sm gap-1">
|
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">
|
<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"/>
|
<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"/>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ _('Autorizza') }}
|
<span x-text="supervisorMode === 'pin' ? '{{ _('Conferma') }}' : '{{ _('Autorizza') }}'"></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -912,6 +1016,18 @@
|
|||||||
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||||
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
|
||||||
|
{# #46: acquisition + overlay for camera_measure / dxf_compare #}
|
||||||
|
<script>
|
||||||
|
window.__visionCfg = {
|
||||||
|
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
||||||
|
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
||||||
|
taskId: {{ task.id|tojson }},
|
||||||
|
taskType: {{ (task.task_type or '')|tojson }},
|
||||||
|
subtasks: window.__taskSubtasks,
|
||||||
|
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
||||||
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
{# 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. #}
|
measurement lives - are shared with the other screens rather than restated. #}
|
||||||
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
||||||
@@ -971,6 +1087,11 @@ function taskExecute() {
|
|||||||
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
||||||
supervisorUsername: '',
|
supervisorUsername: '',
|
||||||
supervisorPassword: '',
|
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: '',
|
supervisorError: '',
|
||||||
supervisorValidating: false,
|
supervisorValidating: false,
|
||||||
/* The out-of-tolerance measurement waiting for the capoturno:
|
/* The out-of-tolerance measurement waiting for the capoturno:
|
||||||
@@ -1180,11 +1301,18 @@ function taskExecute() {
|
|||||||
async loadProductionRun() {
|
async loadProductionRun() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('{{ url_for("measure.api_current_production") }}');
|
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();
|
const run = await resp.json();
|
||||||
if (run && run.id) this.adoptProductionRun(run);
|
if (run && run.id) this.adoptProductionRun(run);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Offline or server down: leave the page usable, just without the timer.
|
// #61: offline o server giu: pagina usabile, ma l'errore e' visibile.
|
||||||
|
this.productionError = '{{ _("Errore di connessione") }}';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1697,13 +1825,9 @@ function taskExecute() {
|
|||||||
this.showCompletionOverlay = false;
|
this.showCompletionOverlay = false;
|
||||||
const opened = await this.startProduction();
|
const opened = await this.startProduction();
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
// Server refused: keep the operator working rather than stranding them
|
// #61: server rifiuta -> niente timer locale finto; si riapre l'overlay
|
||||||
// mid-shift, but productionError is on screen so it is not silent.
|
// con l'errore (productionError) cosi' l'operatore puo' riprovare.
|
||||||
this.cycleConfirmed = true;
|
this.showCompletionOverlay = true;
|
||||||
this.cycleCount++;
|
|
||||||
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
|
||||||
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The START-phase measurements have just been taken: record them as the first
|
// The START-phase measurements have just been taken: record them as the first
|
||||||
@@ -1751,10 +1875,17 @@ function taskExecute() {
|
|||||||
|
|
||||||
// ---- Open/close supervisor modal (always reset fields) ----
|
// ---- Open/close supervisor modal (always reset fields) ----
|
||||||
openSupervisorModal(action) {
|
openSupervisorModal(action) {
|
||||||
|
// #61: fermo/ripresa/fine produzione agiscono su un run: senza run aperto
|
||||||
|
// si dice subito, prima di chiedere le credenziali.
|
||||||
|
if (['fermo_linea', 'ripresa', 'fine_produzione'].includes(action) && !this.productionRun) {
|
||||||
|
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.supervisorAction = action;
|
this.supervisorAction = action;
|
||||||
this.supervisorUsername = '';
|
this.supervisorUsername = '';
|
||||||
this.supervisorPassword = '';
|
this.supervisorPassword = '';
|
||||||
this.supervisorError = '';
|
this.supervisorError = '';
|
||||||
|
this.resetSupervisorPin(); // #59: PIN fields cleared, names reloaded
|
||||||
this.showSupervisorModal = true;
|
this.showSupervisorModal = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1763,6 +1894,68 @@ function taskExecute() {
|
|||||||
this.supervisorUsername = '';
|
this.supervisorUsername = '';
|
||||||
this.supervisorPassword = '';
|
this.supervisorPassword = '';
|
||||||
this.supervisorError = '';
|
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.
|
/* What kind of task this is, as declared in the recipe.
|
||||||
@@ -1776,6 +1969,12 @@ function taskExecute() {
|
|||||||
return declared === 'measure' || declared === 'camera_measure';
|
return declared === 'measure' || declared === 'camera_measure';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* #46: image-based tasks run a VisionSuite recipe on the server. */
|
||||||
|
get isVisionTask() {
|
||||||
|
const t = this.task.task_type;
|
||||||
|
return t === 'camera_measure' || t === 'dxf_compare';
|
||||||
|
},
|
||||||
|
|
||||||
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
||||||
loud rather than silently rendering an empty screen. */
|
loud rather than silently rendering an empty screen. */
|
||||||
get isMeasureTaskWithoutQuotes() {
|
get isMeasureTaskWithoutQuotes() {
|
||||||
@@ -1827,16 +2026,15 @@ function taskExecute() {
|
|||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(this.supervisorCredentials()), // #59: PIN or password
|
||||||
supervisor_username: this.supervisorUsername,
|
|
||||||
supervisor_password: this.supervisorPassword,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await resp.json().catch(() => null);
|
const data = await resp.json().catch(() => null);
|
||||||
|
|
||||||
if (!resp.ok || (data && data.error)) {
|
if (!resp.ok || (data && data.error)) {
|
||||||
this.supervisorError = (data && data.detail)
|
this.supervisorError = (data && data.detail)
|
||||||
|
|| (this.supervisorMode === 'pin' && resp.status === 401 ? '{{ _("PIN errato") }}' : '')
|
||||||
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
|| '{{ _("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;
|
this.supervisorValidating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1871,10 +2069,7 @@ function taskExecute() {
|
|||||||
|
|
||||||
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
|
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
|
||||||
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
|
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
|
||||||
const run = await this.postProduction(base, {
|
const run = await this.postProduction(base, this.supervisorCredentials()); // #59: PIN or password
|
||||||
supervisor_username: this.supervisorUsername,
|
|
||||||
supervisor_password: this.supervisorPassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.supervisorValidating = false;
|
this.supervisorValidating = false;
|
||||||
if (!run) {
|
if (!run) {
|
||||||
@@ -1882,6 +2077,7 @@ function taskExecute() {
|
|||||||
// capoturno sees why the action was refused, rather than a silent no-op.
|
// capoturno sees why the action was refused, rather than a silent no-op.
|
||||||
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
|
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
|
||||||
this.productionError = '';
|
this.productionError = '';
|
||||||
|
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN / lockout: pad cleared, modal stays
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ _('AVVIA IN SEQUENZA') }}
|
{{ _('AVVIA ATTIVITÀ') }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% 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>
|
</select>
|
||||||
</div>
|
</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 -->
|
<!-- Subtask -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
||||||
@@ -258,6 +271,8 @@ function spcDashboard() {
|
|||||||
// Filter state
|
// Filter state
|
||||||
selectedRecipeId: '',
|
selectedRecipeId: '',
|
||||||
selectedSubtaskId: '',
|
selectedSubtaskId: '',
|
||||||
|
selectedRunId: '', // #58
|
||||||
|
runs: [], // #58 sessioni della ricetta
|
||||||
dateFrom: '',
|
dateFrom: '',
|
||||||
dateTo: '',
|
dateTo: '',
|
||||||
subtasks: [],
|
subtasks: [],
|
||||||
@@ -281,6 +296,8 @@ function spcDashboard() {
|
|||||||
|
|
||||||
async onRecipeChange() {
|
async onRecipeChange() {
|
||||||
this.selectedSubtaskId = '';
|
this.selectedSubtaskId = '';
|
||||||
|
this.selectedRunId = ''; // #58
|
||||||
|
this.runs = [];
|
||||||
this.subtasks = [];
|
this.subtasks = [];
|
||||||
this.hasData = false;
|
this.hasData = false;
|
||||||
this.summary = null;
|
this.summary = null;
|
||||||
@@ -290,6 +307,15 @@ function spcDashboard() {
|
|||||||
|
|
||||||
if (!this.selectedRecipeId) return;
|
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
|
// Load subtasks for dropdown
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
|
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
|
||||||
@@ -302,6 +328,12 @@ function spcDashboard() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #58: etichetta sessione = data, stazione, operatore, lotto
|
||||||
|
runLabel(r) {
|
||||||
|
const d = r.started_at ? new Date(r.started_at + (r.started_at.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
|
||||||
|
return [d, r.station_name, r.operator_name, r.lot_number].filter(Boolean).join(' · ');
|
||||||
|
},
|
||||||
|
|
||||||
async loadData() {
|
async loadData() {
|
||||||
if (!this.selectedRecipeId) return;
|
if (!this.selectedRecipeId) return;
|
||||||
|
|
||||||
@@ -312,8 +344,9 @@ function spcDashboard() {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('recipe_id', this.selectedRecipeId);
|
params.set('recipe_id', this.selectedRecipeId);
|
||||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||||
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
|
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
||||||
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
|
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||||
|
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parallel fetch of all endpoints
|
// Parallel fetch of all endpoints
|
||||||
@@ -417,8 +450,9 @@ function spcDashboard() {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('recipe_id', this.selectedRecipeId);
|
params.set('recipe_id', this.selectedRecipeId);
|
||||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||||
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
|
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
|
||||||
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
|
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
|
||||||
|
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
|
||||||
|
|
||||||
const url = '/statistics/api/report-' + type + '?' + params.toString();
|
const url = '/statistics/api/report-' + type + '?' + params.toString();
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""#50/#51: tab Utenti dentro Impostazioni, redirect da /admin/users, reset password."""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
USER = {
|
||||||
|
"id": 2, "username": "mario", "display_name": "Mario", "email": None,
|
||||||
|
"roles": ["MeasurementTec"], "is_admin": False, "language_pref": "it",
|
||||||
|
"theme_pref": "light", "active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_admin_api():
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.get.side_effect = api_get_router({
|
||||||
|
"/api/users": [USER],
|
||||||
|
"/api/settings": {"auto_logout_minutes": "30"},
|
||||||
|
})
|
||||||
|
with patch("blueprints.admin.api_client", mock):
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_users_url_redirects_to_settings_tab(logged_in_client, mock_admin_api):
|
||||||
|
resp = logged_in_client.get("/admin/users")
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert resp.headers["Location"].endswith("/admin/settings?tab=utenti")
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_has_users_tab_and_reset_for_admin(logged_in_client, mock_admin_api):
|
||||||
|
resp = logged_in_client.get("/admin/settings?tab=utenti")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.data.decode("utf-8")
|
||||||
|
assert "userManagement(" in html # pannello utenti incluso
|
||||||
|
assert "openReset(user)" in html # pulsante reset password (#51)
|
||||||
|
assert "mario" in html # utente caricato
|
||||||
|
assert "tab: " in html and "utenti" in html # tab iniziale richiesta
|
||||||
|
|
||||||
|
|
||||||
|
def test_navbar_has_no_users_link(logged_in_client, mock_admin_api):
|
||||||
|
html = logged_in_client.get("/admin/settings").data.decode("utf-8")
|
||||||
|
assert 'href="/admin/users"' not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_cannot_reach_users_tab(client, mock_admin_api):
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["api_key"] = "k"
|
||||||
|
sess["user"] = {"id": 3, "username": "op", "roles": ["MeasurementTec"],
|
||||||
|
"is_admin": False, "language_pref": "it", "theme_pref": "light"}
|
||||||
|
for url in ("/admin/settings?tab=utenti", "/admin/users"):
|
||||||
|
resp = client.get(url)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert "/admin/" not in resp.headers["Location"]
|
||||||
|
mock_admin_api.get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_reset_proxy(logged_in_client, mock_admin_api):
|
||||||
|
mock_admin_api.put.return_value = {"message": "ok"}
|
||||||
|
resp = logged_in_client.put("/admin/api/users/2/password", json={"password": "Nuova1234"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
mock_admin_api.put.assert_called_once_with(
|
||||||
|
"/api/users/2/password", data={"password": "Nuova1234"})
|
||||||
@@ -136,3 +136,67 @@ class TestLanguageSwitch:
|
|||||||
with client.session_transaction() as sess:
|
with client.session_transaction() as sess:
|
||||||
# Invalid language should NOT be stored
|
# Invalid language should NOT be stored
|
||||||
assert sess.get("language") != "xx"
|
assert sess.get("language") != "xx"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPinLogin:
|
||||||
|
"""Login Operatore con nome + PIN (#59)."""
|
||||||
|
|
||||||
|
USER = {
|
||||||
|
"id": 7, "username": "mario", "display_name": "Mario Rossi",
|
||||||
|
"roles": ["MeasurementTec"], "is_admin": False,
|
||||||
|
"language_pref": "it", "theme_pref": "light", "active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_login_page_has_pin_view_and_password_link(self, client):
|
||||||
|
resp = client.get("/auth/login")
|
||||||
|
assert b"pin-pad.js" in resp.data
|
||||||
|
assert b"pin-pad" in resp.data # tastierino presente nel markup
|
||||||
|
assert b'name="password"' in resp.data # form password ancora presente
|
||||||
|
|
||||||
|
def test_pin_users_listed(self, client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = [
|
||||||
|
{"id": 7, "display_name": "Mario Rossi", "secret": "x"},
|
||||||
|
]
|
||||||
|
resp = client.get("/auth/pin-users")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == [{"id": 7, "display_name": "Mario Rossi"}]
|
||||||
|
|
||||||
|
def test_pin_users_empty_on_error(self, client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = {"error": True, "detail": "boom"}
|
||||||
|
assert client.get("/auth/pin-users").get_json() == []
|
||||||
|
|
||||||
|
def test_pin_users_empty_list_falls_back(self, client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = []
|
||||||
|
assert client.get("/auth/pin-users").get_json() == []
|
||||||
|
|
||||||
|
def test_login_pin_success_sets_session(self, client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"api_key": "pin-key", "user": self.USER}
|
||||||
|
mock_api_client.get.return_value = {}
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "1234"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["ok"] is True
|
||||||
|
assert resp.get_json()["redirect"]
|
||||||
|
mock_api_client.post.assert_called_once_with(
|
||||||
|
"/api/auth/login-pin", data={"user_id": 7, "pin": "1234"})
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
assert sess["api_key"] == "pin-key"
|
||||||
|
assert sess["user_id"] == 7
|
||||||
|
assert sess.permanent is True
|
||||||
|
|
||||||
|
def test_login_pin_wrong_pin_401(self, client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"error": True, "status_code": 401, "detail": "x"}
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json()["detail"] == "PIN errato"
|
||||||
|
|
||||||
|
def test_login_pin_locked_423_shows_detail(self, client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"error": True, "status_code": 423, "detail": "Utente bloccato fino alle 10:15"}
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "0000"})
|
||||||
|
assert resp.status_code == 423
|
||||||
|
assert "10:15" in resp.get_json()["detail"]
|
||||||
|
|
||||||
|
def test_login_pin_invalid_pin_422(self, client, mock_api_client):
|
||||||
|
resp = client.post("/auth/login-pin", json={"user_id": 7, "pin": "12"})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Filtro local_dt: il backend parla UTC, la presentazione e' in Europe/Rome (#42)."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def local_dt(flask_app):
|
||||||
|
return flask_app.jinja_env.filters["local_dt"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_summer_time(local_dt):
|
||||||
|
assert local_dt("2026-10-06T08:00:00+00:00") == "06/10/2026 10:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_winter_time(local_dt):
|
||||||
|
assert local_dt("2026-01-15T08:00:00+00:00") == "15/01/2026 09:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_naive_is_utc(local_dt):
|
||||||
|
assert local_dt("2026-10-06T08:00:00") == "06/10/2026 10:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_datetime_object_and_format(local_dt):
|
||||||
|
dt = datetime(2026, 10, 6, 8, 0, tzinfo=timezone.utc)
|
||||||
|
assert local_dt(dt, "%H:%M") == "10:00"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("empty", [None, ""])
|
||||||
|
def test_empty(local_dt, empty):
|
||||||
|
assert local_dt(empty) == "-"
|
||||||
@@ -71,6 +71,61 @@ class TestRecipeNew:
|
|||||||
assert resp.status_code == 200
|
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:
|
class TestRoleRequired:
|
||||||
"""Maker role enforcement tests."""
|
"""Maker role enforcement tests."""
|
||||||
|
|
||||||
@@ -161,3 +216,109 @@ class TestVersionConflictProxy:
|
|||||||
|
|
||||||
assert resp.status_code == 409
|
assert resp.status_code == 409
|
||||||
assert resp.get_json()["detail"] == self._CONFLICT["detail"]
|
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
|
||||||
|
|||||||
@@ -407,6 +407,21 @@ class TestTaskComplete:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"productionClock(" in resp.data
|
assert b"productionClock(" in resp.data
|
||||||
|
|
||||||
|
def test_measured_at_shown_in_local_time(self, logged_in_client, mock_api_client):
|
||||||
|
"""#42: the API sends UTC; the summary shows Europe/Rome."""
|
||||||
|
mock_api_client.get.side_effect = [
|
||||||
|
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
|
||||||
|
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
|
||||||
|
{"id": 1, "marker_number": 1, "description": "d", "nominal": 1.0,
|
||||||
|
"unit": "mm"}]}],
|
||||||
|
None,
|
||||||
|
{"items": [{"id": 1, "subtask_id": 1, "value": 1.0, "pass_fail": "pass",
|
||||||
|
"measured_at": "2026-10-06T08:00:00+00:00"}]},
|
||||||
|
]
|
||||||
|
resp = logged_in_client.get("/measure/complete/1?version_id=5")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "06/10/2026 10:00" in resp.get_data(as_text=True)
|
||||||
|
|
||||||
def test_measurements_filtered_by_lot_serial_and_operator(
|
def test_measurements_filtered_by_lot_serial_and_operator(
|
||||||
self, logged_in_client, mock_api_client,
|
self, logged_in_client, mock_api_client,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Capoturno authorisation by PIN (#59): proxies of the measure blueprint.
|
||||||
|
|
||||||
|
The modal in task_execute.html sends either ``supervisor_user_id`` + ``supervisor_pin``
|
||||||
|
or the old ``supervisor_username`` + ``supervisor_password``; the proxies forward
|
||||||
|
whichever pair is present and let the PIN/lockout refusals through to the modal.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
RUN = {"id": 7, "status": "paused"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def measure_mod():
|
||||||
|
from blueprints import measure
|
||||||
|
return measure
|
||||||
|
|
||||||
|
|
||||||
|
def test_pin_users_proxy_returns_only_id_and_name(logged_in_client, measure_mod):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.get.return_value = [
|
||||||
|
{"id": 3, "display_name": "Anna Rossi", "pin_hash": "x"},
|
||||||
|
{"id": 4, "display_name": "bruno"},
|
||||||
|
]
|
||||||
|
resp = logged_in_client.get("/measure/api/pin-users")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == [
|
||||||
|
{"id": 3, "display_name": "Anna Rossi"},
|
||||||
|
{"id": 4, "display_name": "bruno"},
|
||||||
|
]
|
||||||
|
assert mock_api.get.call_args[0][0] == "/api/auth/pin-users"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pin_users_proxy_empty_on_backend_error(logged_in_client, measure_mod):
|
||||||
|
"""The modal opens the Password tab when the list is empty."""
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.get.return_value = {"error": True, "status_code": 502, "detail": "down"}
|
||||||
|
resp = logged_in_client.get("/measure/api/pin-users")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", ["pause", "resume", "close"])
|
||||||
|
def test_supervised_action_forwards_user_id_and_pin(logged_in_client, measure_mod, action):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = RUN
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
f"/measure/api/production/7/{action}",
|
||||||
|
json={"supervisor_user_id": 5, "supervisor_pin": "1234"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
endpoint, kwargs = mock_api.post.call_args
|
||||||
|
assert endpoint[0] == f"/api/production-runs/7/{action}"
|
||||||
|
assert kwargs["data"]["supervisor_user_id"] == 5
|
||||||
|
assert kwargs["data"]["supervisor_pin"] == "1234"
|
||||||
|
assert "supervisor_username" not in kwargs["data"]
|
||||||
|
assert "supervisor_password" not in kwargs["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_supervised_action_password_pair_unchanged(logged_in_client, measure_mod):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = RUN
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/pause",
|
||||||
|
json={"supervisor_username": "capo", "supervisor_password": "segreta"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
kwargs = mock_api.post.call_args[1]
|
||||||
|
assert kwargs["data"]["supervisor_username"] == "capo"
|
||||||
|
assert kwargs["data"]["supervisor_password"] == "segreta"
|
||||||
|
assert "supervisor_pin" not in kwargs["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_supervised_action_pin_without_user_is_refused(logged_in_client, measure_mod):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/pause", json={"supervisor_pin": "1234"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorise_measurement_forwards_user_id_and_pin(logged_in_client, measure_mod):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {"id": 9}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/measurements/9/authorise",
|
||||||
|
json={"supervisor_user_id": 5, "supervisor_pin": "4321"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
endpoint, kwargs = mock_api.post.call_args
|
||||||
|
assert endpoint[0] == "/api/measurements/9/authorise"
|
||||||
|
assert kwargs["data"] == {"supervisor_user_id": 5, "supervisor_pin": "4321"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_pin_401_stays_in_the_modal_and_keeps_the_session(logged_in_client, measure_mod):
|
||||||
|
"""api_client empties the session on any 401; a wrong PIN must not log the operator out."""
|
||||||
|
def refuse(*args, **kwargs):
|
||||||
|
session.clear() # what api_client._handle_session_expired does
|
||||||
|
return {
|
||||||
|
"error": True, "status_code": 401, "session_expired": True,
|
||||||
|
"detail": "Invalid supervisor credentials",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.side_effect = refuse
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/close",
|
||||||
|
json={"supervisor_user_id": 5, "supervisor_pin": "0000"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["detail"] == "PIN errato"
|
||||||
|
assert "session_expired" not in body
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
assert sess.get("api_key") == "test-api-key-12345"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_expired_session_401_still_clears_the_session(logged_in_client, measure_mod):
|
||||||
|
"""A 401 that is not about the capoturno keeps its session_expired flag."""
|
||||||
|
def expired(*args, **kwargs):
|
||||||
|
session.clear()
|
||||||
|
return {
|
||||||
|
"error": True, "status_code": 401, "session_expired": True,
|
||||||
|
"detail": "Invalid API key",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.side_effect = expired
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/close",
|
||||||
|
json={"supervisor_user_id": 5, "supervisor_pin": "0000"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.get_json()["session_expired"] is True
|
||||||
|
with logged_in_client.session_transaction() as sess:
|
||||||
|
assert "api_key" not in sess
|
||||||
|
|
||||||
|
|
||||||
|
def test_locked_user_423_passes_the_detail(logged_in_client, measure_mod):
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {
|
||||||
|
"error": True, "status_code": 423,
|
||||||
|
"detail": "Utente bloccato fino alle 10:15",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/pause",
|
||||||
|
json={"supervisor_user_id": 5, "supervisor_pin": "1111"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 423
|
||||||
|
assert resp.get_json()["detail"] == "Utente bloccato fino alle 10:15"
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_401_detail_is_kept(logged_in_client, measure_mod):
|
||||||
|
"""Password mode: the refusal text is the backend's, as before."""
|
||||||
|
with patch.object(measure_mod, "api_client") as mock_api:
|
||||||
|
mock_api.post.return_value = {
|
||||||
|
"error": True, "status_code": 403, "detail": "User is not authorised as supervisor",
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/production/7/pause",
|
||||||
|
json={"supervisor_username": "op", "supervisor_password": "x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert resp.get_json()["detail"] == "User is not authorised as supervisor"
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""gitea:Adriano/TieMeasureFlow#46 - operator side of the vision tasks.
|
||||||
|
|
||||||
|
The backend of contract B is not merged yet: api_client is mocked.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from tests.conftest import api_get_router
|
||||||
|
|
||||||
|
TASK = {
|
||||||
|
"id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure",
|
||||||
|
"title": "Foto", "directive": None, "description": None, "file_path": None,
|
||||||
|
"file_type": None, "annotations_json": None, "subtasks": [],
|
||||||
|
}
|
||||||
|
RECIPE = {
|
||||||
|
"id": 3, "code": "REC-1", "name": "Ricetta",
|
||||||
|
"measurement_interval_minutes": 30, "allow_manual_input": True,
|
||||||
|
"current_version": {"id": 5, "version_number": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_page_has_acquire_input(logged_in_client, mock_api_client, monkeypatch):
|
||||||
|
"""camera_measure screen offers the file/camera input and the acquire entry point."""
|
||||||
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
|
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
|
||||||
|
mock_api_client.get.side_effect = api_get_router({
|
||||||
|
"/api/tasks/12": TASK,
|
||||||
|
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
|
||||||
|
"/api/recipes/3": RECIPE,
|
||||||
|
})
|
||||||
|
html = logged_in_client.get("/measure/execute/12").get_data(as_text=True)
|
||||||
|
assert 'capture="environment"' in html
|
||||||
|
assert 'accept="image/*"' in html
|
||||||
|
assert "visionExecute()" in html
|
||||||
|
assert "/measure/api/vision/execute" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_execute_proxy_forwards_multipart(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {
|
||||||
|
"vision_result_id": 7, "outcome": "ok", "overlay": [], "failures": [],
|
||||||
|
"measurements": [{"subtask_id": 1, "value": 9.9, "pass_fail": "pass"}],
|
||||||
|
}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/vision/execute",
|
||||||
|
data={
|
||||||
|
"task_id": "12", "lot_number": "L1", "serial_number": "S1",
|
||||||
|
"production_run_id": "4",
|
||||||
|
"image": (io.BytesIO(b"\xff\xd8\xff fake"), "shot.jpg", "image/jpeg"),
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["vision_result_id"] == 7
|
||||||
|
args, kwargs = mock_api_client.post.call_args
|
||||||
|
assert args[0] == "/api/vision/execute"
|
||||||
|
assert kwargs["data"]["task_id"] == "12"
|
||||||
|
assert kwargs["data"]["lot_number"] == "L1"
|
||||||
|
assert kwargs["data"]["production_run_id"] == "4"
|
||||||
|
name, content, mime = kwargs["files"]["image"]
|
||||||
|
assert name == "shot.jpg" and mime == "image/jpeg" and content.startswith(b"\xff\xd8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_execute_proxy_requires_image(logged_in_client, mock_api_client):
|
||||||
|
resp = logged_in_client.post("/measure/api/vision/execute", data={"task_id": "12"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
mock_api_client.post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_execute_proxy_relays_backend_error(logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.post.return_value = {"error": True, "status_code": 422, "detail": "bad"}
|
||||||
|
resp = logged_in_client.post(
|
||||||
|
"/measure/api/vision/execute",
|
||||||
|
data={"task_id": "12", "image": (io.BytesIO(b"x"), "a.jpg", "image/jpeg")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.get_json()["detail"] == "bad"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_result_image_proxy(logged_in_client):
|
||||||
|
fake = MagicMock(status_code=200, content=b"IMG", headers={"content-type": "image/jpeg"})
|
||||||
|
with patch("blueprints.measure.http_requests.get", return_value=fake) as g:
|
||||||
|
resp = logged_in_client.get("/measure/api/vision/results/7/image")
|
||||||
|
assert resp.status_code == 200 and resp.data == b"IMG"
|
||||||
|
assert g.call_args[0][0].endswith("/api/vision/results/7/image")
|
||||||
|
assert g.call_args[1]["headers"]["X-API-Key"] == "test-api-key-12345"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Live station monitor page and JSON proxy (#57)."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_api():
|
||||||
|
with patch("blueprints.monitor.api_client") as m:
|
||||||
|
yield m
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, roles, is_admin=False):
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess["api_key"] = "k"
|
||||||
|
sess["user"] = {"id": 2, "username": "u", "display_name": "U", "roles": roles,
|
||||||
|
"is_admin": is_admin, "language_pref": "en", "theme_pref": "light",
|
||||||
|
"active": True}
|
||||||
|
sess["language"] = "en"
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_ok_for_supervisor(client, mock_api):
|
||||||
|
_login(client, ["Supervisor"])
|
||||||
|
resp = client.get("/monitor")
|
||||||
|
assert resp.status_code == 200 and b"stationMonitor" in resp.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_forbidden_for_operator(client, mock_api):
|
||||||
|
_login(client, ["MeasurementTec"])
|
||||||
|
assert client.get("/monitor").status_code == 403
|
||||||
|
assert client.get("/monitor/api/runs").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_returns_backend_json(client, mock_api):
|
||||||
|
_login(client, ["Supervisor"])
|
||||||
|
mock_api.get.return_value = [{"id": 1, "station_code": "ST-1"}]
|
||||||
|
resp = client.get("/monitor/api/runs")
|
||||||
|
assert resp.status_code == 200 and resp.get_json()[0]["station_code"] == "ST-1"
|
||||||
|
mock_api.get.assert_called_once_with("/api/production-runs/open")
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_passes_backend_error(client, mock_api):
|
||||||
|
_login(client, [], is_admin=True)
|
||||||
|
mock_api.get.return_value = {"error": True, "status_code": 403, "detail": "no"}
|
||||||
|
resp = client.get("/monitor/api/runs")
|
||||||
|
assert resp.status_code == 403 and resp.get_json()["error"] is True
|
||||||
@@ -104,7 +104,7 @@ def test_the_selection_screen_starts_the_sequence(
|
|||||||
def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client):
|
def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client):
|
||||||
html = _list_page(logged_in_client, mock_api_client)
|
html = _list_page(logged_in_client, mock_api_client)
|
||||||
assert "/measure/start/3" in html
|
assert "/measure/start/3" in html
|
||||||
assert "AVVIA IN SEQUENZA" in html
|
assert "AVVIA ATTIVITÀ" in html
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -163,6 +163,32 @@ def test_the_header_offers_completato_not_the_summary(
|
|||||||
assert '@click="goToNextTask()"' in html
|
assert '@click="goToNextTask()"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_one_completato_and_it_is_big(
|
||||||
|
logged_in_client, mock_api_client, monkeypatch,
|
||||||
|
):
|
||||||
|
"""#60: «Completato» una sola volta (in basso), largo e alto per il touch."""
|
||||||
|
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
|
||||||
|
# the label as a button text on its own line (comments in the JS also name it)
|
||||||
|
assert [l.strip() for l in html.splitlines()].count("Completato") == 1
|
||||||
|
assert "min-h-[64px]" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_open_run_means_no_fermo_linea_and_no_fake_timer(
|
||||||
|
logged_in_client, mock_api_client, monkeypatch,
|
||||||
|
):
|
||||||
|
"""#61: senza produzione aperta i pulsanti non si vedono, la modale non si
|
||||||
|
apre e un rifiuto del server non avvia un timer locale finto."""
|
||||||
|
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
|
||||||
|
assert 'x-if="isMeasureTask && productionRun"' in html
|
||||||
|
assert "!this.productionRun" in html
|
||||||
|
# #61: loadProductionRun mostra il detail del server, non solo il messaggio generico
|
||||||
|
assert "errBody.detail" in html
|
||||||
|
assert "Nessuna produzione aperta su questa stazione" in html
|
||||||
|
# il vecchio ripiego locale non c'e' piu'
|
||||||
|
assert "this.startCountdownFrom(this.measurementIntervalMinutes * 60)" not in html.split(
|
||||||
|
"async startProductionFromOverlay()")[1].split("async confirmCycle")[0]
|
||||||
|
|
||||||
|
|
||||||
def test_fine_ciclo_is_shown_before_it_can_be_used(
|
def test_fine_ciclo_is_shown_before_it_can_be_used(
|
||||||
logged_in_client, mock_api_client, monkeypatch,
|
logged_in_client, mock_api_client, monkeypatch,
|
||||||
):
|
):
|
||||||
@@ -196,3 +222,27 @@ def test_the_operator_flow_never_links_to_the_statistics(
|
|||||||
]
|
]
|
||||||
for html in pages:
|
for html in pages:
|
||||||
assert "/statistics" not in html
|
assert "/statistics" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_panel_only_for_recipes_that_require_it(
|
||||||
|
logged_in_client, mock_api_client, monkeypatch,
|
||||||
|
):
|
||||||
|
"""#63/#64: ricerca+griglia in alto, pannello lotto/seriale solo dopo la
|
||||||
|
scelta di una ricetta che lo richiede, con i soli campi richiesti."""
|
||||||
|
_italian(logged_in_client)
|
||||||
|
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
|
||||||
|
mock_api_client.get_station_recipes.return_value = [
|
||||||
|
{"id": 1, "code": "A", "name": "Con lotto", "requires_lot": True,
|
||||||
|
"requires_serial": False},
|
||||||
|
{"id": 2, "code": "B", "name": "Libera"},
|
||||||
|
]
|
||||||
|
html = logged_in_client.get("/measure/select").get_data(as_text=True)
|
||||||
|
assert 'id="trace-panel"' in html
|
||||||
|
assert 'x-show="selected && needsTrace(selected)"' in html
|
||||||
|
assert 'x-show="selected && selected.requires_lot"' in html
|
||||||
|
assert 'x-show="selected && selected.requires_serial"' in html
|
||||||
|
# la ricerca e la griglia precedono il pannello
|
||||||
|
assert html.index("Cerca ricetta") < html.index('id="trace-panel"')
|
||||||
|
assert html.index("Recipe Grid") < html.index('id="trace-panel"')
|
||||||
|
# le ricette senza obblighi partono direttamente
|
||||||
|
assert 'x-if="!needsTrace(recipe)"' in html
|
||||||
|
|||||||
@@ -112,3 +112,21 @@ class TestSPCProxy:
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
assert "cp" in data
|
assert "cp" in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunsProxy:
|
||||||
|
"""#58: filtro per sessione di produzione."""
|
||||||
|
|
||||||
|
def test_runs_proxy(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = [{"id": 5, "measurement_count": 3}]
|
||||||
|
resp = logged_in_client.get("/statistics/api/runs?recipe_id=7")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()[0]["id"] == 5
|
||||||
|
args, kwargs = mock_api_client.get.call_args
|
||||||
|
assert args[0] == "/api/statistics/runs"
|
||||||
|
assert kwargs["params"] == {"recipe_id": "7"}
|
||||||
|
|
||||||
|
def test_summary_forwards_production_run_id(self, logged_in_client, mock_api_client):
|
||||||
|
mock_api_client.get.return_value = {"total": 1}
|
||||||
|
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
|
||||||
|
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ def test_admin_stations_inline_js_is_valid_in_english(
|
|||||||
|
|
||||||
|
|
||||||
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
||||||
"""Same guard for /admin/users (reuses the userManagement Alpine pattern)."""
|
"""Same guard for the Users tab, now inside /admin/settings (#50)."""
|
||||||
mock_admin_api.get.return_value = [{
|
mock_admin_api.get.return_value = [{
|
||||||
"id": 1, "username": "admin", "display_name": "Admin",
|
"id": 1, "username": "admin", "display_name": "Admin",
|
||||||
"email": None, "roles": ["Maker"], "is_admin": True,
|
"email": None, "roles": ["Maker"], "is_admin": True,
|
||||||
@@ -264,7 +264,7 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
|||||||
}]
|
}]
|
||||||
|
|
||||||
_force_italian(logged_in_client)
|
_force_italian(logged_in_client)
|
||||||
resp = logged_in_client.get("/admin/users")
|
resp = logged_in_client.get("/admin/settings")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
html = resp.data.decode("utf-8")
|
html = resp.data.decode("utf-8")
|
||||||
@@ -272,9 +272,9 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
|
|||||||
assert scripts
|
assert scripts
|
||||||
|
|
||||||
for i, body in enumerate(scripts):
|
for i, body in enumerate(scripts):
|
||||||
_node_check(body, f"/admin/users script[{i}]")
|
_node_check(body, f"/admin/settings script[{i}]")
|
||||||
|
|
||||||
_check_alpine_attributes(html, "/admin/users")
|
_check_alpine_attributes(html, "/admin/settings")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -1862,8 +1862,8 @@ msgstr "No description available"
|
|||||||
|
|
||||||
#: templates/measure/select_recipe.html:294
|
#: templates/measure/select_recipe.html:294
|
||||||
#: templates/measure/task_list.html:107
|
#: templates/measure/task_list.html:107
|
||||||
msgid "AVVIA IN SEQUENZA"
|
msgid "AVVIA ATTIVITÀ"
|
||||||
msgstr "START THE SEQUENCE"
|
msgstr "START ACTIVITY"
|
||||||
|
|
||||||
# Maker - Task Editor
|
# Maker - Task Editor
|
||||||
#: templates/measure/select_recipe.html:302
|
#: templates/measure/select_recipe.html:302
|
||||||
|
|||||||
@@ -1864,8 +1864,8 @@ msgstr "Nessuna descrizione disponibile"
|
|||||||
|
|
||||||
#: templates/measure/select_recipe.html:294
|
#: templates/measure/select_recipe.html:294
|
||||||
#: templates/measure/task_list.html:107
|
#: templates/measure/task_list.html:107
|
||||||
msgid "AVVIA IN SEQUENZA"
|
msgid "AVVIA ATTIVITÀ"
|
||||||
msgstr "AVVIA IN SEQUENZA"
|
msgstr "AVVIA ATTIVITÀ"
|
||||||
|
|
||||||
# Maker - Task Editor
|
# Maker - Task Editor
|
||||||
#: templates/measure/select_recipe.html:302
|
#: templates/measure/select_recipe.html:302
|
||||||
|
|||||||
@@ -1825,7 +1825,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: templates/measure/select_recipe.html:294
|
#: templates/measure/select_recipe.html:294
|
||||||
#: templates/measure/task_list.html:107
|
#: templates/measure/task_list.html:107
|
||||||
msgid "AVVIA IN SEQUENZA"
|
msgid "AVVIA ATTIVITÀ"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: templates/measure/select_recipe.html:302
|
#: templates/measure/select_recipe.html:302
|
||||||
|
|||||||
+117
-22
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -18,6 +19,9 @@ from typing import Any
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from visionsuite.core.image import Image
|
from visionsuite.core.image import Image
|
||||||
from visionsuite.task import Task
|
from visionsuite.task import Task
|
||||||
|
from visionsuite.task.graph import RecipeError # #46: errori di ricetta v2
|
||||||
|
from visionsuite.task.registry import load_tools
|
||||||
|
from visionsuite.task.session import Session
|
||||||
|
|
||||||
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
|
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
|
||||||
|
|
||||||
@@ -49,6 +53,10 @@ class RunOutcome:
|
|||||||
# Kept apart from `failures`: the tool did not raise, it just produced a
|
# Kept apart from `failures`: the tool did not raise, it just produced a
|
||||||
# number the caller must not measure a part by. Value is the reason.
|
# number the caller must not measure a part by. Value is the reason.
|
||||||
invalid_outputs: dict[str, str] = field(default_factory=dict)
|
invalid_outputs: dict[str, str] = field(default_factory=dict)
|
||||||
|
# #46: contorni di tutti gli strumenti (px immagine, con tool_id) e esito
|
||||||
|
# del TaskReport (informativo: il verdetto quote lo dà il backend).
|
||||||
|
overlay: list[dict] = field(default_factory=list)
|
||||||
|
outcome: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
@@ -97,12 +105,46 @@ def engine_version() -> str:
|
|||||||
return _engine_version_from_git()
|
return _engine_version_from_git()
|
||||||
|
|
||||||
|
|
||||||
|
def _scale_calibration(calibration: dict | None):
|
||||||
|
"""Dict {"mm_per_px"|"px_per_mm"} -> ScaleCalibration (gli strumenti di
|
||||||
|
quota convertono con `in_millimetres`, che non accetta un dict)."""
|
||||||
|
if calibration is None:
|
||||||
|
return None
|
||||||
|
from visionsuite.calibration import ScaleCalibration
|
||||||
|
|
||||||
|
if not isinstance(calibration, dict) or not (
|
||||||
|
calibration.get("mm_per_px") or calibration.get("px_per_mm")
|
||||||
|
):
|
||||||
|
raise GraphBuildError(
|
||||||
|
"calibration must be {'mm_per_px': float} or {'px_per_mm': float}"
|
||||||
|
)
|
||||||
|
key = "mm_per_px" if calibration.get("mm_per_px") else "px_per_mm"
|
||||||
|
return ScaleCalibration(**{key: float(calibration[key])})
|
||||||
|
|
||||||
|
|
||||||
|
def _open_session(graph: dict, recipe_dir: Any, calibration: dict | None,
|
||||||
|
workdir: str) -> Session:
|
||||||
|
"""Costruisce la Session v2; RecipeError e grafo malformato -> GraphBuildError."""
|
||||||
|
cal = _scale_calibration(calibration)
|
||||||
|
try:
|
||||||
|
return Session.from_dict(
|
||||||
|
graph, Path(recipe_dir) if recipe_dir else Path(workdir), cal,
|
||||||
|
)
|
||||||
|
except RecipeError as exc:
|
||||||
|
raise GraphBuildError(f"malformed graph: {exc}") from exc
|
||||||
|
except (KeyError, TypeError, AttributeError, ValueError) as exc:
|
||||||
|
raise GraphBuildError(
|
||||||
|
f"malformed graph: {type(exc).__name__}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def run_graph(
|
def run_graph(
|
||||||
image: Any,
|
image: Any,
|
||||||
graph: dict,
|
graph: dict,
|
||||||
calibration: dict | None = None,
|
calibration: dict | None = None,
|
||||||
|
recipe_dir: Any = None,
|
||||||
) -> RunOutcome:
|
) -> RunOutcome:
|
||||||
"""Run a serialised vs-task graph against one image.
|
"""Run a serialised vs-task v2 graph against one image.
|
||||||
|
|
||||||
A tool that fails does not stop the others - on a crooked piece an edge may
|
A tool that fails does not stop the others - on a crooked piece an edge may
|
||||||
simply not be there, and the other quotes are still wanted. Failures come
|
simply not be there, and the other quotes are still wanted. Failures come
|
||||||
@@ -112,33 +154,35 @@ def run_graph(
|
|||||||
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
|
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
|
||||||
actually read via `.pixels`; an `Image` passed in is used as-is.
|
actually read via `.pixels`; an `Image` passed in is used as-is.
|
||||||
|
|
||||||
Two phases, on purpose: building the task from `graph` can fail on
|
`recipe_dir` is the folder of the recipe's files (meta.dxf, meta.models,
|
||||||
nothing but the caller's input (a bad schema version, a tool with no
|
...); without it an empty temporary folder is used, so a recipe that needs
|
||||||
"kind", a graph that is not a dict at all) - that is `GraphBuildError`,
|
a file fails as a malformed graph instead of reading from the CWD.
|
||||||
a 422 for the worker. Once the task exists, `task.run()` is the engine's
|
|
||||||
own business; an exception from there is not the caller's mistake, and
|
Two phases, on purpose: building the session can fail on nothing but the
|
||||||
the worker must not call it one.
|
caller's input (a bad schema version, a tool with no "kind", a missing
|
||||||
|
file) - that is `GraphBuildError`, a 422 for the worker. Once it exists,
|
||||||
|
running is the engine's own business; an exception from there is not the
|
||||||
|
caller's mistake, and the worker must not call it one.
|
||||||
"""
|
"""
|
||||||
try:
|
load_tools() # #46: registra gli strumenti degli entry point di vs-tools
|
||||||
task = Task.from_dict(graph)
|
with tempfile.TemporaryDirectory() as empty_dir:
|
||||||
except (KeyError, TypeError, AttributeError, ValueError) as exc:
|
session = _open_session(graph, recipe_dir, calibration, empty_dir)
|
||||||
raise GraphBuildError(
|
wrapped_image = (
|
||||||
f"malformed graph: {type(exc).__name__}: {exc}"
|
image if isinstance(image, Image) else Image(pixels=np.asarray(image))
|
||||||
) from exc
|
)
|
||||||
|
started = time.monotonic()
|
||||||
wrapped_image = image if isinstance(image, Image) else Image(pixels=np.asarray(image))
|
report = session.run(wrapped_image)
|
||||||
context: dict[str, Any] = {"image": wrapped_image}
|
elapsed_ms = int((time.monotonic() - started) * 1000)
|
||||||
if calibration is not None:
|
|
||||||
context["calibration"] = calibration
|
|
||||||
|
|
||||||
started = time.monotonic()
|
|
||||||
report = task.run(context=context)
|
|
||||||
elapsed_ms = int((time.monotonic() - started) * 1000)
|
|
||||||
|
|
||||||
outputs: dict[str, float] = {}
|
outputs: dict[str, float] = {}
|
||||||
failures: list[ToolFailure] = []
|
failures: list[ToolFailure] = []
|
||||||
invalid_outputs: dict[str, str] = {}
|
invalid_outputs: dict[str, str] = {}
|
||||||
|
overlay: list[dict] = []
|
||||||
for result in report:
|
for result in report:
|
||||||
|
# #46: overlay di ogni strumento, con il tool_id di chi lo ha prodotto
|
||||||
|
for item in result.overlay or []:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
overlay.append({**item, "tool_id": result.tool_id})
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
failures.append(ToolFailure(
|
failures.append(ToolFailure(
|
||||||
tool_id=result.tool_id,
|
tool_id=result.tool_id,
|
||||||
@@ -164,4 +208,55 @@ def run_graph(
|
|||||||
engine_version=engine_version(),
|
engine_version=engine_version(),
|
||||||
duration_ms=elapsed_ms,
|
duration_ms=elapsed_ms,
|
||||||
invalid_outputs=invalid_outputs,
|
invalid_outputs=invalid_outputs,
|
||||||
|
overlay=overlay,
|
||||||
|
outcome=report.outcome,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_number(value: Any) -> bool:
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_graph(
|
||||||
|
graph: dict, recipe_dir: Any = None
|
||||||
|
) -> tuple[bool, list[str], list[dict]]:
|
||||||
|
"""Validate a v2 recipe without an image: (ok, errors, numeric outputs).
|
||||||
|
|
||||||
|
Not a draft: a missing meta file is an error. `outputs` lists the numeric
|
||||||
|
outputs the tools declare (OUT properties with a numeric default, plus the
|
||||||
|
`values` of dimension tools), with quantity/unit when declared.
|
||||||
|
"""
|
||||||
|
load_tools()
|
||||||
|
if not isinstance(graph, dict):
|
||||||
|
return False, ["graph must be a JSON object"], []
|
||||||
|
try:
|
||||||
|
task = Task.from_dict(
|
||||||
|
graph, recipe_dir=Path(recipe_dir) if recipe_dir else None
|
||||||
|
)
|
||||||
|
except RecipeError as exc:
|
||||||
|
return False, list(exc.errors), []
|
||||||
|
except (KeyError, TypeError, AttributeError, ValueError) as exc:
|
||||||
|
return False, [f"{type(exc).__name__}: {exc}"], []
|
||||||
|
|
||||||
|
outputs: list[dict] = []
|
||||||
|
for tool in task.tools:
|
||||||
|
value_quantities = getattr(tool, "value_quantities", None) or {}
|
||||||
|
values = tuple(getattr(tool, "values", ()) or ())
|
||||||
|
for prop in tool.outputs():
|
||||||
|
if not (_is_number(prop.default) or prop.name in values):
|
||||||
|
continue
|
||||||
|
quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None))
|
||||||
|
if not isinstance(quantity, str):
|
||||||
|
quantity = None
|
||||||
|
unit = None
|
||||||
|
if quantity == "length":
|
||||||
|
nominal_unit = getattr(tool, "nominal_unit", None)
|
||||||
|
unit = nominal_unit if isinstance(nominal_unit, str) else None
|
||||||
|
elif quantity == "angle":
|
||||||
|
unit = "deg"
|
||||||
|
outputs.append({
|
||||||
|
"key": f"{tool.id}.{prop.name}",
|
||||||
|
"quantity": quantity,
|
||||||
|
"unit": unit,
|
||||||
|
})
|
||||||
|
return True, [], outputs
|
||||||
|
|||||||
+146
-13
@@ -34,14 +34,16 @@ def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
|
|||||||
def a_circle_graph() -> dict:
|
def a_circle_graph() -> dict:
|
||||||
"""A two-tool graph, built with vs-task's own API."""
|
"""A two-tool graph, built with vs-task's own API."""
|
||||||
from visionsuite.task import Task
|
from visionsuite.task import Task
|
||||||
from visionsuite.task.tools import DetectCircleTool, DiameterTool
|
from visionsuite.tools.detect import DetectCircle
|
||||||
|
from visionsuite.tools.dimensions import Diameter
|
||||||
|
|
||||||
task = Task(name="prova")
|
task = Task(name="prova")
|
||||||
found = task.add(DetectCircleTool(name="disco"))
|
found = task.add(DetectCircle(name="disco"))
|
||||||
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
|
||||||
found.scan_size = 30.0
|
found.scan_size = 30.0
|
||||||
|
|
||||||
quota = task.add(DiameterTool(name="diametro"))
|
quota = task.add(Diameter(name="diametro"))
|
||||||
|
quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px
|
||||||
quota.link("circle", found, "circle")
|
quota.link("circle", found, "circle")
|
||||||
return task.to_dict()
|
return task.to_dict()
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ def test_a_broken_tool_does_not_sink_the_others():
|
|||||||
graph = a_circle_graph()
|
graph = a_circle_graph()
|
||||||
graph["tools"].append(
|
graph["tools"].append(
|
||||||
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
|
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
|
||||||
"properties": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
|
"values": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
|
||||||
)
|
)
|
||||||
|
|
||||||
outcome = run_graph(a_disc(), graph)
|
outcome = run_graph(a_disc(), graph)
|
||||||
@@ -81,14 +83,21 @@ class _FakeResult:
|
|||||||
self.ok = ok
|
self.ok = ok
|
||||||
self.outputs = outputs or {}
|
self.outputs = outputs or {}
|
||||||
self.error = error
|
self.error = error
|
||||||
|
self.overlay = [] # #46: v2, ogni risultato porta l'overlay
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeReport(list):
|
||||||
|
"""#46: run_graph ora legge `.outcome` dal report di Session.run."""
|
||||||
|
|
||||||
|
outcome = "pass"
|
||||||
|
|
||||||
|
|
||||||
def test_a_nan_output_is_dropped_not_measured(monkeypatch):
|
def test_a_nan_output_is_dropped_not_measured(monkeypatch):
|
||||||
"""vs-measure emits NaN on an empty slice (caliper.py:112 per the
|
"""vs-measure emits NaN on an empty slice (caliper.py:112 per the
|
||||||
revisione) - a NaN must not become a quote's value (#16)."""
|
revisione) - a NaN must not become a quote's value (#16)."""
|
||||||
def fake_run(self, context=None):
|
def fake_run(self, image, tag=None):
|
||||||
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})]
|
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})])
|
||||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||||
|
|
||||||
outcome = run_graph(a_disc(), a_circle_graph())
|
outcome = run_graph(a_disc(), a_circle_graph())
|
||||||
|
|
||||||
@@ -97,9 +106,9 @@ def test_a_nan_output_is_dropped_not_measured(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
|
def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
|
||||||
def fake_run(self, context=None):
|
def fake_run(self, image, tag=None):
|
||||||
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})]
|
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})])
|
||||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||||
|
|
||||||
outcome = run_graph(a_disc(), a_circle_graph())
|
outcome = run_graph(a_disc(), a_circle_graph())
|
||||||
|
|
||||||
@@ -109,9 +118,9 @@ def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
|
|||||||
|
|
||||||
def test_a_bool_output_is_not_treated_as_a_measurable_number(monkeypatch):
|
def test_a_bool_output_is_not_treated_as_a_measurable_number(monkeypatch):
|
||||||
"""bool is a subclass of int - a flag output must not become a quote."""
|
"""bool is a subclass of int - a flag output must not become a quote."""
|
||||||
def fake_run(self, context=None):
|
def fake_run(self, image, tag=None):
|
||||||
return [_FakeResult("t1", "flag", True, outputs={"present": True})]
|
return _FakeReport([_FakeResult("t1", "flag", True, outputs={"present": True})])
|
||||||
monkeypatch.setattr(runner.Task, "run", fake_run)
|
monkeypatch.setattr(runner.Session, "run", fake_run)
|
||||||
|
|
||||||
outcome = run_graph(a_disc(), a_circle_graph())
|
outcome = run_graph(a_disc(), a_circle_graph())
|
||||||
|
|
||||||
@@ -155,3 +164,127 @@ def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
|
|||||||
engine_version()
|
engine_version()
|
||||||
finally:
|
finally:
|
||||||
runner._engine_version_from_git.cache_clear()
|
runner._engine_version_from_git.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #46: ricette v2 complete - overlay, calibrazione, recipe_dir con DXF.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
_VS = Path(__file__).resolve().parents[3] / "vendor" / "visionsuite"
|
||||||
|
_SAMPLE_DXF = _VS / "packages" / "vs-dxf" / "tests" / "fixtures" / "sample.dxf"
|
||||||
|
# Il disegno di prova: rettangolo 200x100 mm con foro r=10 mm.
|
||||||
|
_PX_PER_MM = 4.0
|
||||||
|
|
||||||
|
|
||||||
|
def _dxf_scene() -> np.ndarray:
|
||||||
|
"""Il pezzo del disegno rasterizzato a 4 px/mm, margine 60 mm."""
|
||||||
|
margin = 60.0
|
||||||
|
w = int((200.0 + 2 * margin) * _PX_PER_MM)
|
||||||
|
h = int((100.0 + 2 * margin) * _PX_PER_MM)
|
||||||
|
img = np.full((h, w), 255, dtype=np.uint8)
|
||||||
|
cx, cy = w // 2, h // 2
|
||||||
|
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
|
||||||
|
cv2.rectangle(img, (int(cx - hw), int(cy - hh)), (int(cx + hw), int(cy + hh)), 0, -1)
|
||||||
|
cv2.circle(img, (cx, cy), int(10.0 * _PX_PER_MM), 255, -1)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _dxf_recipe() -> dict:
|
||||||
|
"""CompareDxf con ancora a due punti, collegati a due DetectPointScan.
|
||||||
|
|
||||||
|
Le due scansioni trovano i bordi sinistro e destro del pezzo una riga
|
||||||
|
sopra il lato in basso: origine del disegno = angolo in basso a sinistra,
|
||||||
|
direzione a -> b = asse x del disegno.
|
||||||
|
"""
|
||||||
|
h, w = _dxf_scene().shape
|
||||||
|
cx, cy = w // 2, h // 2
|
||||||
|
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
|
||||||
|
row = cy + hh - 1.0
|
||||||
|
|
||||||
|
def scan(tid: str, col: float) -> dict:
|
||||||
|
return {"id": tid, "name": tid, "kind": "DetectPointScan",
|
||||||
|
"values": {"start_row": row, "start_col": col - 30.0,
|
||||||
|
"end_row": row, "end_col": col + 30.0,
|
||||||
|
"width": 5.0, "select": "first"},
|
||||||
|
"links": {}}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": 2,
|
||||||
|
"name": "dxf",
|
||||||
|
"meta": {"dxf": {"disegno": { # sha256 obbligatorio in meta (v2)
|
||||||
|
"path": "disegno.dxf",
|
||||||
|
"sha256": hashlib.sha256(_SAMPLE_DXF.read_bytes()).hexdigest()}}},
|
||||||
|
"tools": [
|
||||||
|
scan("t-a", cx - hw),
|
||||||
|
scan("t-b", cx + hw),
|
||||||
|
{"id": "t-dxf", "name": "confronto", "kind": "CompareDxf",
|
||||||
|
"values": {"dxf": "disegno", "mm_per_px": 1.0 / _PX_PER_MM,
|
||||||
|
"anchor": "two_points",
|
||||||
|
"band_plus": 8.0, "band_minus": 8.0},
|
||||||
|
"links": {"point_a": {"tool": "t-a", "property": "point"},
|
||||||
|
"point_b": {"tool": "t-b", "property": "point"}}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_circle_recipe_returns_overlay_and_outcome():
|
||||||
|
outcome = run_graph(a_disc(), a_circle_graph())
|
||||||
|
|
||||||
|
assert outcome.outcome in ("pass", "warning", "fail")
|
||||||
|
assert outcome.overlay, "DetectCircle must draw something"
|
||||||
|
assert all("tool_id" in item for item in outcome.overlay)
|
||||||
|
|
||||||
|
|
||||||
|
def test_calibration_dict_gives_mm_quotes():
|
||||||
|
graph = a_circle_graph()
|
||||||
|
for tool in graph["tools"]:
|
||||||
|
if tool["kind"] == "Diameter":
|
||||||
|
tool["values"]["nominal_unit"] = "mm"
|
||||||
|
|
||||||
|
outcome = run_graph(a_disc(), graph, calibration={"mm_per_px": 0.5})
|
||||||
|
|
||||||
|
assert outcome.failures == []
|
||||||
|
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".value"))
|
||||||
|
assert diameter == pytest.approx(60.0, abs=2.0) # 120 px * 0.5 mm/px
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_dxf_two_points_with_recipe_dir(tmp_path):
|
||||||
|
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
|
||||||
|
|
||||||
|
outcome = run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
|
||||||
|
|
||||||
|
assert outcome.failures == []
|
||||||
|
assert outcome.outputs["t-dxf.n_points"] > 1000
|
||||||
|
assert outcome.outputs["t-dxf.points_in_pct"] > 95.0
|
||||||
|
roles = {item.get("role") for item in outcome.overlay}
|
||||||
|
assert {"reference", "found"} <= roles
|
||||||
|
assert "t-dxf" in {item["tool_id"] for item in outcome.overlay}
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_meta_file_is_a_graph_build_error(tmp_path):
|
||||||
|
with pytest.raises(runner.GraphBuildError):
|
||||||
|
run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_lists_numeric_outputs(tmp_path):
|
||||||
|
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
|
||||||
|
graph = _dxf_recipe()
|
||||||
|
|
||||||
|
ok, errors, outputs = runner.validate_graph(graph, tmp_path)
|
||||||
|
|
||||||
|
assert ok, errors
|
||||||
|
keys = {o["key"] for o in outputs}
|
||||||
|
assert {"t-dxf.points_in_pct", "t-dxf.n_points"} <= keys
|
||||||
|
assert all(set(o) == {"key", "quantity", "unit"} for o in outputs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_reports_a_missing_file(tmp_path):
|
||||||
|
ok, errors, outputs = runner.validate_graph(_dxf_recipe(), tmp_path)
|
||||||
|
|
||||||
|
assert not ok and errors and outputs == []
|
||||||
|
|||||||
+109
-17
@@ -10,12 +10,20 @@ import asyncio
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
from src.vision.runner import GraphBuildError, RunOutcome, engine_version, run_graph
|
from src.vision.runner import (
|
||||||
|
GraphBuildError,
|
||||||
|
RunOutcome,
|
||||||
|
engine_version,
|
||||||
|
run_graph,
|
||||||
|
validate_graph,
|
||||||
|
)
|
||||||
|
|
||||||
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
|
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
|
||||||
|
|
||||||
@@ -31,6 +39,69 @@ _MAX_CONCURRENCY = int(os.environ.get("VISION_MAX_CONCURRENCY", "2"))
|
|||||||
_run_slots = asyncio.Semaphore(_MAX_CONCURRENCY)
|
_run_slots = asyncio.Semaphore(_MAX_CONCURRENCY)
|
||||||
|
|
||||||
|
|
||||||
|
# #46: radice sotto cui vivono le cartelle delle ricette (volume uploads,
|
||||||
|
# montato in sola lettura nel worker). `recipe_dir` e' relativo a questa radice.
|
||||||
|
_RECIPES_ROOT_DEFAULT = "/app/uploads"
|
||||||
|
|
||||||
|
|
||||||
|
def _unprocessable(detail: str) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_recipe_dir(recipe_dir: str | None) -> Path | None:
|
||||||
|
"""Percorso RELATIVO sotto VISION_RECIPES_ROOT -> cartella assoluta.
|
||||||
|
|
||||||
|
Assoluto, `..` o fuori radice (anche via symlink) -> 422. Vuoto -> None.
|
||||||
|
"""
|
||||||
|
if recipe_dir is None or not recipe_dir.strip():
|
||||||
|
return None
|
||||||
|
rel = recipe_dir.strip()
|
||||||
|
# Letto a ogni richiesta (non a import) cosi' i test possono cambiarlo.
|
||||||
|
root = Path(os.environ.get("VISION_RECIPES_ROOT", _RECIPES_ROOT_DEFAULT)).resolve()
|
||||||
|
if (
|
||||||
|
Path(rel).is_absolute()
|
||||||
|
# #46: lettera di unita (C:/x) rifiutata su ogni OS; UNC coperto da startswith("\\")
|
||||||
|
or re.match(r"^[A-Za-z]:", rel)
|
||||||
|
or rel.startswith(("/", "\\"))
|
||||||
|
or ".." in Path(rel.replace("\\", "/")).parts
|
||||||
|
):
|
||||||
|
raise _unprocessable("recipe_dir must be a relative path under the recipes root")
|
||||||
|
resolved = (root / rel).resolve()
|
||||||
|
if resolved != root and root not in resolved.parents:
|
||||||
|
raise _unprocessable("recipe_dir is outside the recipes root")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_graph(graph: str) -> dict:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(graph)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise _unprocessable(f"graph is not valid JSON: {exc}") from exc
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise _unprocessable(
|
||||||
|
f"graph must be a JSON object, got {type(parsed).__name__}"
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_calibration(calibration: str | None) -> dict | None:
|
||||||
|
if calibration is None or not calibration.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(calibration)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise _unprocessable(f"calibration is not valid JSON: {exc}") from exc
|
||||||
|
if not isinstance(parsed, dict) or not (
|
||||||
|
parsed.get("mm_per_px") or parsed.get("px_per_mm")
|
||||||
|
):
|
||||||
|
raise _unprocessable(
|
||||||
|
"calibration must be {'mm_per_px': float} or {'px_per_mm': float}"
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
class _ImageDecodeError(Exception):
|
class _ImageDecodeError(Exception):
|
||||||
"""The upload is not a decodable image - raised inside the worker thread
|
"""The upload is not a decodable image - raised inside the worker thread
|
||||||
so it survives the trip through `asyncio.to_thread`."""
|
so it survives the trip through `asyncio.to_thread`."""
|
||||||
@@ -44,7 +115,12 @@ async def health() -> dict:
|
|||||||
return {"status": "ok", "engine_version": engine_version()}
|
return {"status": "ok", "engine_version": engine_version()}
|
||||||
|
|
||||||
|
|
||||||
def _decode_and_run(content: bytes, graph: dict) -> RunOutcome:
|
def _decode_and_run(
|
||||||
|
content: bytes,
|
||||||
|
graph: dict,
|
||||||
|
calibration: dict | None = None,
|
||||||
|
recipe_dir: Path | None = None,
|
||||||
|
) -> RunOutcome:
|
||||||
"""The synchronous, CPU/IO-bound half of a request: PIL decode plus
|
"""The synchronous, CPU/IO-bound half of a request: PIL decode plus
|
||||||
`run_graph`. Runs in a worker thread (see `run` below) so the event loop -
|
`run_graph`. Runs in a worker thread (see `run` below) so the event loop -
|
||||||
and therefore `/health` - stays responsive while it executes. Once this
|
and therefore `/health` - stays responsive while it executes. Once this
|
||||||
@@ -56,27 +132,19 @@ def _decode_and_run(content: bytes, graph: dict) -> RunOutcome:
|
|||||||
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
|
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
|
||||||
except (UnidentifiedImageError, OSError) as exc:
|
except (UnidentifiedImageError, OSError) as exc:
|
||||||
raise _ImageDecodeError(str(exc)) from exc
|
raise _ImageDecodeError(str(exc)) from exc
|
||||||
return run_graph(frame, graph)
|
return run_graph(frame, graph, calibration=calibration, recipe_dir=recipe_dir)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/run")
|
@app.post("/run")
|
||||||
async def run(
|
async def run(
|
||||||
image: UploadFile = File(...),
|
image: UploadFile = File(...),
|
||||||
graph: str = Form(...),
|
graph: str = Form(...),
|
||||||
|
recipe_dir: str | None = Form(None),
|
||||||
|
calibration: str | None = Form(None),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
try:
|
parsed_graph = _parse_graph(graph)
|
||||||
parsed_graph = json.loads(graph)
|
resolved_dir = _resolve_recipe_dir(recipe_dir)
|
||||||
except json.JSONDecodeError as exc:
|
parsed_calibration = _parse_calibration(calibration)
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=f"graph is not valid JSON: {exc}",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not isinstance(parsed_graph, dict):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=f"graph must be a JSON object, got {type(parsed_graph).__name__}",
|
|
||||||
)
|
|
||||||
|
|
||||||
content = await image.read()
|
content = await image.read()
|
||||||
|
|
||||||
@@ -89,7 +157,13 @@ async def run(
|
|||||||
async with _run_slots:
|
async with _run_slots:
|
||||||
try:
|
try:
|
||||||
outcome = await asyncio.wait_for(
|
outcome = await asyncio.wait_for(
|
||||||
asyncio.to_thread(_decode_and_run, content, parsed_graph),
|
asyncio.to_thread(
|
||||||
|
_decode_and_run,
|
||||||
|
content,
|
||||||
|
parsed_graph,
|
||||||
|
parsed_calibration,
|
||||||
|
resolved_dir,
|
||||||
|
),
|
||||||
timeout=_RUN_TIMEOUT_S,
|
timeout=_RUN_TIMEOUT_S,
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError as exc:
|
except asyncio.TimeoutError as exc:
|
||||||
@@ -130,4 +204,22 @@ async def run(
|
|||||||
"engine_version": outcome.engine_version,
|
"engine_version": outcome.engine_version,
|
||||||
"duration_ms": outcome.duration_ms,
|
"duration_ms": outcome.duration_ms,
|
||||||
"invalid_outputs": outcome.invalid_outputs,
|
"invalid_outputs": outcome.invalid_outputs,
|
||||||
|
# #46: contorni per l'overlay sull'immagine e esito VisionSuite.
|
||||||
|
"overlay": outcome.overlay,
|
||||||
|
"outcome": outcome.outcome,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/validate")
|
||||||
|
async def validate(
|
||||||
|
graph: str = Form(...),
|
||||||
|
recipe_dir: str | None = Form(None),
|
||||||
|
) -> dict:
|
||||||
|
"""Validates a v2 recipe (not a draft: a missing meta file is an error)
|
||||||
|
and lists its numeric outputs. No image, so it is cheap: no run slot."""
|
||||||
|
parsed_graph = _parse_graph(graph)
|
||||||
|
resolved_dir = _resolve_recipe_dir(recipe_dir)
|
||||||
|
ok, errors, outputs = await asyncio.to_thread(
|
||||||
|
validate_graph, parsed_graph, resolved_dir
|
||||||
|
)
|
||||||
|
return {"ok": ok, "errors": errors, "outputs": outputs}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user