Compare commits

..

4 Commits

Author SHA1 Message Date
Adriano 07e23b957d PIN a 4 cifre: migrazione 017, login-pin, pin-users, PUT pin, capoturno via PIN (#59)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:27:31 +02:00
Adriano 86290274e5 Test ricetta v2, execute dxf_compare, migrazione 016 (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:15:02 +02:00
Adriano ee0f65ed39 Endpoint ricetta VisionSuite v2, execute con overlay e immagine salvata (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:12:50 +02:00
Adriano 18d0db8a0c Migrazione 016 vision_recipe_dir, copy-on-write, dxf_compare tra i task di misura (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:08:29 +02:00
32 changed files with 1359 additions and 619 deletions
+38 -1
View File
@@ -1,5 +1,6 @@
"""Authentication router - login, logout, profile."""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
@@ -8,10 +9,17 @@ from src.backend.models.orm.user import User
from src.backend.models.api.user import (
LoginRequest,
LoginResponse,
PinLoginRequest,
PinUserItem,
UserProfileUpdate,
UserResponse,
)
from src.backend.services.auth_service import authenticate_user, login_user, logout_user
from src.backend.services.auth_service import (
authenticate_pin,
authenticate_user,
login_user,
logout_user,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -32,6 +40,35 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
)
@router.get("/pin-users", response_model=list[PinUserItem])
async def pin_users(db: AsyncSession = Depends(get_db)):
"""Utenti che possono entrare con PIN (#59): attivi, non admin, con PIN.
Pubblico per costruzione (serve prima del login): espone solo id e nome.
"""
result = await db.execute(
select(User).where(
User.active == True, # noqa: E712
User.is_admin == False, # noqa: E712
User.pin_hash.is_not(None),
)
)
items = [
PinUserItem(id=u.id, display_name=u.display_name or u.username)
for u in result.scalars().all()
]
items.sort(key=lambda i: i.display_name.casefold())
return items
@router.post("/login-pin", response_model=LoginResponse)
async def login_pin(data: PinLoginRequest, db: AsyncSession = Depends(get_db)):
"""Login con user_id e PIN a 4 cifre (#59); stessa risposta del login."""
user = await authenticate_pin(db, data.user_id, data.pin)
api_key = await login_user(db, user)
return LoginResponse(user=UserResponse.model_validate(user), api_key=api_key)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
+1
View File
@@ -147,6 +147,7 @@ async def authorise_measurement(
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
measurement = await measurement_service.authorise_measurement(
db, measurement, supervisor,
+3
View File
@@ -141,6 +141,7 @@ async def pause_production_run(
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
@@ -157,6 +158,7 @@ async def resume_production_run(
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
@@ -173,6 +175,7 @@ async def close_production_run(
run = await production_service.get_run(db, run_id)
supervisor = await auth_service.authorise_supervisor(
db, action.supervisor_username, action.supervisor_password,
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
)
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
return await _as_response(db, run)
-43
View File
@@ -1,7 +1,4 @@
"""System settings router - read/update settings, upload company logo."""
import json
from decimal import Decimal, InvalidOperation
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -21,23 +18,6 @@ _VALID_CSV_DELIMITERS = {",", ";", "\t", "|"}
_VALID_CSV_DECIMAL_SEPARATORS = {",", "."}
def _validate_string_list(key: str, value: str) -> None:
"""#49: array JSON di stringhe non vuote, max 50 voci (vuoto = nessuna lista)."""
try:
items = json.loads(value)
except ValueError:
items = None
if (
not isinstance(items, list)
or len(items) > 50
or not all(isinstance(i, str) and i.strip() for i in items)
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{key} deve essere vuoto o un array JSON di al massimo 50 stringhe non vuote",
)
def _validate_setting_value(key: str, value: str) -> None:
"""Raise 422 for a value that would break the code reading this key.
@@ -61,29 +41,6 @@ def _validate_setting_value(key: str, value: str) -> None:
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="auto_logout_minutes deve essere vuoto (disabilitato) o un intero fra 1 e 480",
)
elif key == "default_measurement_interval_minutes":
# #47: vuoto = nessun default
if value != "" and (not value.isdigit() or not (1 <= int(value) <= 1440)):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="default_measurement_interval_minutes deve essere vuoto o un intero fra 1 e 1440",
)
elif key in ("general_tolerance_plus", "general_tolerance_minus"):
# #48: decimale >= 0 con punto; vuoto = nessun default
if value != "":
try:
number = Decimal(value)
ok = number.is_finite() and number >= 0
except InvalidOperation:
ok = False
if not ok:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{key} deve essere vuoto o un numero decimale maggiore o uguale a 0 (punto decimale)",
)
elif key in ("list_measurement_types", "list_units"):
if value != "":
_validate_string_list(key, value)
elif key in ("company_name", "company_logo_path"):
if not value.strip() or len(value) > 200:
raise HTTPException(
+32 -2
View File
@@ -6,8 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_admin_user
from src.backend.models.orm.user import User
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys
from src.backend.models.api.user import PinSet, UserCreate, UserPasswordChange, UserResponse, UserUpdate
from src.backend.services.auth_service import (
create_user,
hash_password,
regenerate_api_key,
revoke_all_keys,
set_user_pin,
)
router = APIRouter(prefix="/api/users", tags=["users"])
@@ -151,6 +157,30 @@ async def change_user_password(
return {"message": f"Password changed for user {user.username}"}
@router.put("/{user_id}/pin", status_code=status.HTTP_200_OK)
async def change_user_pin(
user_id: int,
data: PinSet,
admin: User = Depends(require_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Imposta o toglie (pin null) il PIN di un utente (admin, #59/#51).
422 se l'utente e' admin. Il PIN non si restituisce mai.
"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user.is_admin:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Admin users cannot have a PIN",
)
await set_user_pin(db, user, data.pin)
return {"message": f"PIN {'set' if data.pin else 'removed'} for user {user.username}"}
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_user(
user_id: int,
+115 -3
View File
@@ -5,19 +5,25 @@ the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
import io
import json
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from PIL import Image
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.middleware.api_key import require_maker, require_measurement_tec
from src.backend.api.routers.files import ALLOWED_IMAGE_TYPES, validate_file_size
from src.backend.api.routers.files import (
ALLOWED_IMAGE_TYPES, resolve_upload_path, validate_file_size,
)
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.models.api.vision import (
VisionExecuteResponse, VisionMeasurementResult, VisionReferenceImageResponse,
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionRecipeUploadResponse, VisionReferenceImageResponse,
)
from src.backend.models.orm.vision import VisionResult
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import vision_service
@@ -126,7 +132,7 @@ async def execute(
) -> VisionExecuteResponse:
task = await _task_or_404(db, task_id)
result, saved = await vision_service.execute_task(
result, saved, report = await vision_service.execute_task(
db,
task,
await _read_image(image),
@@ -135,6 +141,7 @@ async def execute(
lot_number=lot_number,
serial_number=serial_number,
production_run_id=production_run_id,
image_ext=_IMAGE_EXTENSIONS.get(image.content_type, "png"),
)
await db.commit()
@@ -147,6 +154,111 @@ async def execute(
)
for m in saved
],
outcome=report.get("outcome"),
overlay=report.get("overlay") or [],
image_url=f"/api/vision/results/{result.id}/image",
failures=report.get("failures") or [],
)
@router.get("/results/{result_id}/image")
async def get_result_image(
result_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> FileResponse:
"""The image a vision run measured, as saved by `execute` (#46)."""
result = (await db.execute(
select(VisionResult).where(VisionResult.id == result_id)
)).scalar_one_or_none()
if result is None or not result.image_path:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Result image not found",
)
return FileResponse(resolve_upload_path(result.image_path))
def _422(errors: list[str]) -> HTTPException:
# detail e' una lista di stringhe: il frontend legge i 422 solo da `detail`.
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=errors,
)
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
async def upload_recipe(
task_id: int,
recipe: UploadFile = File(...),
files: list[UploadFile] = File(default=[]),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> VisionRecipeUploadResponse:
"""Load a VisionSuite v2 recipe plus its meta files onto a task (#46)."""
task = await _task_or_404(db, task_id)
if task.task_type not in vision_service.VISION_TASK_TYPES:
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
raw = await recipe.read()
if not validate_file_size(len(raw)):
raise _422([f"recipe exceeds {settings.max_upload_size_mb}MB"])
try:
graph = json.loads(raw)
except ValueError:
raise _422(["the recipe is not valid JSON"])
if not isinstance(graph, dict):
raise _422(["the recipe must be a JSON object"])
uploads: list[tuple[str, bytes]] = []
for f in files:
content = await f.read()
if not validate_file_size(len(content)):
raise _422([f"file {f.filename!r} exceeds {settings.max_upload_size_mb}MB"])
uploads.append((f.filename or "", content))
placed, errors = vision_service.match_recipe_files(graph, uploads)
if errors:
raise _422(errors)
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
await db.commit()
return VisionRecipeUploadResponse(
task_id=task.id,
recipe_dir=recipe_dir,
files=vision_service.list_recipe_files(recipe_dir),
outputs=check["outputs"],
)
@router.get("/tasks/{task_id}/recipe", response_model=VisionRecipeResponse)
async def get_recipe(
task_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> VisionRecipeResponse:
task = await _task_or_404(db, task_id)
outputs: list[dict] = []
if task.vision_json:
check = await vision_service.validate_recipe(
task.vision_json, task.vision_recipe_dir,
)
outputs = check.get("outputs", [])
return VisionRecipeResponse(
task_id=task.id,
recipe_dir=task.vision_recipe_dir,
recipe=task.vision_json,
files=vision_service.list_recipe_files(task.vision_recipe_dir),
outputs=outputs,
)
@@ -0,0 +1,30 @@
"""recipe_tasks.vision_recipe_dir: cartella della ricetta VisionSuite v2 (#46)
Percorso relativo sotto upload_path della cartella immutabile che contiene
recipe.json e i file di meta (DXF, modelli .npz, ...).
Revision ID: 016_vision_recipe_dir
Revises: 015_api_key_hash
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '016_vision_recipe_dir'
down_revision: Union[str, None] = '015_api_key_hash'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('vision_recipe_dir', sa.String(500), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.drop_column('vision_recipe_dir')
@@ -0,0 +1,34 @@
"""users: PIN a 4 cifre per login rapido e capoturno (#59)
pin_hash (bcrypt, mai in chiaro), contatore errori consecutivi e fine blocco.
Revision ID: 017_user_pin
Revises: 016_vision_recipe_dir
Create Date: 2026-10-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '017_user_pin'
down_revision: Union[str, None] = '016_vision_recipe_dir'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch: SQLite non sa fare DROP COLUMN nativo su tutte le versioni
with op.batch_alter_table('users') as batch:
batch.add_column(sa.Column('pin_hash', sa.String(255), nullable=True))
batch.add_column(sa.Column(
'pin_failed_count', sa.Integer(), nullable=False, server_default='0'))
batch.add_column(sa.Column('pin_locked_until', sa.DateTime(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('users') as batch:
batch.drop_column('pin_locked_until')
batch.drop_column('pin_failed_count')
batch.drop_column('pin_hash')
+16 -3
View File
@@ -2,7 +2,7 @@
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
class MeasurementCreate(BaseModel):
@@ -71,8 +71,21 @@ class MeasurementListResponse(BaseModel):
class MeasurementAuthorisation(BaseModel):
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
supervisor_username: str = Field(..., min_length=1)
supervisor_password: str = Field(..., min_length=1)
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
supervisor_username: Optional[str] = Field(None, min_length=1)
supervisor_password: Optional[str] = Field(None, min_length=1)
supervisor_user_id: Optional[int] = Field(None, gt=0)
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
@model_validator(mode="after")
def _one_credential_pair(self):
by_password = self.supervisor_username and self.supervisor_password
by_pin = self.supervisor_user_id and self.supervisor_pin
if not (by_password or by_pin):
raise ValueError(
"supervisor credentials required: username+password or user_id+pin"
)
return self
class TaskProgressResponse(BaseModel):
+16 -3
View File
@@ -2,7 +2,7 @@
from src.backend.models.api.tipi import UtcDateTime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
class ProductionRunCreate(BaseModel):
@@ -64,8 +64,21 @@ class ProductionRunWithEventsResponse(ProductionRunResponse):
class SupervisorAction(BaseModel):
"""Credentials of the supervisor authorising a line stop or a close."""
supervisor_username: str = Field(..., min_length=1)
supervisor_password: str = Field(..., min_length=1)
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
supervisor_username: Optional[str] = Field(None, min_length=1)
supervisor_password: Optional[str] = Field(None, min_length=1)
supervisor_user_id: Optional[int] = Field(None, gt=0)
supervisor_pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
@model_validator(mode="after")
def _one_credential_pair(self):
by_password = self.supervisor_username and self.supervisor_password
by_pin = self.supervisor_user_id and self.supervisor_pin
if not (by_password or by_pin):
raise ValueError(
"supervisor credentials required: username+password or user_id+pin"
)
return self
note: Optional[str] = None
+20
View File
@@ -50,6 +50,8 @@ class UserResponse(BaseModel):
active: bool
created_at: UtcDateTime
last_login: Optional[UtcDateTime] = None
# #59: solo il fatto che il PIN esista, mai l'hash
has_pin: bool = False
class UserPasswordChange(BaseModel):
@@ -68,3 +70,21 @@ class LoginResponse(BaseModel):
user: UserResponse
api_key: str
message: str = "Login successful"
class PinUserItem(BaseModel):
"""Voce dell'elenco pubblico per il login con PIN (#59): solo id e nome."""
id: int
display_name: str
class PinLoginRequest(BaseModel):
"""Login con PIN (#59)."""
user_id: int
pin: str = Field(..., pattern=r"^[0-9]{4}$")
class PinSet(BaseModel):
"""Imposta (4 cifre) o toglie (null) il PIN di un utente (#59)."""
pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
+28
View File
@@ -16,6 +16,34 @@ class VisionExecuteResponse(BaseModel):
vision_result_id: int
engine_version: str
measurements: list[VisionMeasurementResult]
# Esito VisionSuite (informativo: il verdetto lo danno utl/ltl), overlay
# in px immagine, URL dell'immagine salvata e strumenti falliti (#46).
outcome: str | None = None
overlay: list[dict] = []
image_url: str | None = None
failures: list[dict] = []
class VisionRecipeOutput(BaseModel):
"""One numeric output a recipe offers to the quotes."""
key: str
quantity: str | None = None
unit: str | None = None
class VisionRecipeUploadResponse(BaseModel):
task_id: int
recipe_dir: str
files: list[str]
outputs: list[VisionRecipeOutput]
class VisionRecipeResponse(BaseModel):
task_id: int
recipe_dir: str | None = None
recipe: dict | None = None
files: list[str]
outputs: list[VisionRecipeOutput]
class VisionReferenceImageResponse(BaseModel):
+5 -1
View File
@@ -18,7 +18,8 @@ if TYPE_CHECKING:
TASK_TYPES = ("note", "measure", "drawing", "dxf_compare", "camera_measure")
# 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):
@@ -51,6 +52,9 @@ class RecipeTask(Base):
# server routes it and never interprets it, which is what keeps VisionSuite
# out of this container.
vision_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# Cartella immutabile (relativa a upload_path) con recipe.json e i file di
# meta della ricetta VisionSuite v2 (#46).
vision_recipe_dir: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Relationships
version: Mapped["RecipeVersion"] = relationship(back_populates="tasks")
+12
View File
@@ -22,6 +22,13 @@ class User(Base):
# Admin flag separate from roles
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# PIN a 4 cifre (#59): solo l'hash; contatore errori e fine blocco per il lockout
pin_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
pin_failed_count: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
pin_locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# User preferences
language_pref: Mapped[str] = mapped_column(
Enum("it", "en", name="language_enum"), nullable=False, default="it"
@@ -39,6 +46,11 @@ class User(Base):
)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
@property
def has_pin(self) -> bool:
"""True se l'utente ha un PIN impostato (#59); serve a UserResponse.has_pin."""
return self.pin_hash is not None
def has_role(self, role: str) -> bool:
"""Check if user has a specific role."""
return role in (self.roles or [])
+83 -2
View File
@@ -54,7 +54,79 @@ async def authenticate_user(
return user
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
# #59: PIN a 4 cifre. Hash identico alle password (bcrypt); 5 errori -> blocco 15 minuti.
PIN_MAX_FAILURES = 5
PIN_LOCK_MINUTES = 15
async def set_user_pin(db: AsyncSession, user: User, pin: str | None) -> None:
"""Imposta o toglie (None) il PIN; azzera contatore e blocco. Admin esclusi (#59)."""
if pin is not None and user.is_admin:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Admin users cannot have a PIN",
)
user.pin_hash = hash_password(pin) if pin is not None else None
user.pin_failed_count = 0
user.pin_locked_until = None
await db.flush()
async def authenticate_pin(db: AsyncSession, user_id: int, pin: str) -> User:
"""Verifica user_id+PIN con blocco dopo 5 errori consecutivi (#59).
401 PIN errato (o utente non idoneo: stessa risposta, niente enumerazione),
423 utente bloccato. Il contatore va committato prima di sollevare: get_db fa
rollback su eccezione e l'errore andrebbe perso.
"""
result = await db.execute(
select(User).where(User.id == user_id, User.active == True) # noqa: E712
)
user = result.scalar_one_or_none()
if user is None or user.is_admin or user.pin_hash is None:
verify_password(pin, _DUMMY_PASSWORD_HASH) # stesso costo di un PIN errato
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN"
)
now = utcnow()
if user.pin_locked_until is not None:
if user.pin_locked_until > now:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail="PIN locked until "
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
+ " UTC",
)
# blocco scaduto: si riparte da zero
user.pin_locked_until = None
user.pin_failed_count = 0
if verify_password(pin, user.pin_hash):
if user.pin_failed_count:
user.pin_failed_count = 0
await db.flush()
return user
user.pin_failed_count += 1
locked = user.pin_failed_count >= PIN_MAX_FAILURES
if locked:
user.pin_locked_until = now + timedelta(minutes=PIN_LOCK_MINUTES)
await db.commit()
if locked:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail="PIN locked until "
+ user.pin_locked_until.strftime("%Y-%m-%d %H:%M:%S")
+ " UTC",
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid PIN")
async def authorise_supervisor(
db: AsyncSession,
username: str | None = None,
password: str | None = None,
user_id: int | None = None,
pin: str | None = None,
) -> User:
"""Check the credentials of the supervisor authorising something.
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
@@ -65,7 +137,16 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
Uses authenticate_user rather than a full login: logging in would issue a
needless API key for the supervisor.
"""
supervisor = await authenticate_user(db, username, password)
if user_id is not None and pin is not None:
# #59: via PIN, stesso blocco del login con PIN (401/423 sollevati da li')
supervisor = await authenticate_pin(db, user_id, pin)
elif username is not None and password is not None:
supervisor = await authenticate_user(db, username, password)
else:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Supervisor credentials required",
)
if supervisor is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
+2
View File
@@ -115,6 +115,8 @@ async def _copy_tasks_to_version(
# a camera_measure task unable to execute from the moment a
# recipe with measurements is next edited.
vision_json=task.vision_json,
# Cartella immutabile: basta il riferimento (#46).
vision_recipe_dir=task.vision_recipe_dir,
)
db.add(new_task)
await db.flush() # get new_task.id
+206 -7
View File
@@ -5,6 +5,7 @@ to the worker over HTTP and stays ignorant of how a number was produced.
"""
from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
@@ -46,7 +47,13 @@ def _is_measurable(value: object) -> bool:
)
async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
async def _call_worker(
image_bytes: bytes,
graph: dict,
*,
recipe_dir: str | None = None,
calibration: dict | None = None,
) -> dict:
"""The one place that talks to the worker; the tests replace it.
The worker's fault must not become the API's fault (spec's edge-case
@@ -60,12 +67,19 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
is not the worker's JSON contract must not turn into a bare KeyError
further down.
"""
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
# si mandano, il worker usa i suoi default.
data = {"graph": json.dumps(graph)}
if recipe_dir:
data["recipe_dir"] = recipe_dir
if calibration:
data["calibration"] = json.dumps(calibration)
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{settings.vision_worker_url}/run",
files={"image": ("frame.png", image_bytes, "image/png")},
data={"graph": json.dumps(graph)},
data=data,
)
except httpx.TimeoutException as exc:
raise HTTPException(
@@ -122,6 +136,180 @@ async def _call_worker(image_bytes: bytes, graph: dict) -> dict:
return report
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
Returns {"ok", "errors", "outputs"}. A worker 422 (e.g. recipe_dir
refused) is folded into ok=False; transport faults become 502/504 as in
`_call_worker`. Replaced by the tests.
"""
data = {"graph": json.dumps(graph)}
if recipe_dir:
data["recipe_dir"] = recipe_dir
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{settings.vision_worker_url}/validate", data=data,
)
except httpx.TimeoutException as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"the vision worker did not answer in time: {exc}",
) from exc
except httpx.HTTPError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc}",
) from exc
if response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT:
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
errors = detail if isinstance(detail, list) else [str(detail)]
return {"ok": False, "errors": [str(e) for e in errors], "outputs": []}
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker refused the job: {response.text}",
)
try:
body = response.json()
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: not valid JSON",
) from exc
if not isinstance(body, dict) or "ok" not in body:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: missing ok",
)
body.setdefault("errors", [])
body.setdefault("outputs", [])
return body
# Task types that run a vision graph (#46).
VISION_TASK_TYPES = ("camera_measure", "dxf_compare")
def _safe_join(root: Path, relative: str) -> Path:
"""root/relative, refusing absolute paths, '..' and anything leaving root."""
candidate = Path(relative)
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
raise ValueError(f"unsafe path: {relative}")
full = (root / candidate).resolve()
if not full.is_relative_to(root.resolve()):
raise ValueError(f"unsafe path: {relative}")
return full
def collect_meta_paths(node: object) -> list[str]:
"""Every `path` string found in any dict under the recipe's `meta`."""
found: list[str] = []
if isinstance(node, dict):
for key, value in node.items():
if key == "path" and isinstance(value, str):
found.append(value)
else:
found.extend(collect_meta_paths(value))
elif isinstance(node, list):
for item in node:
found.extend(collect_meta_paths(item))
return found
def match_recipe_files(
recipe: dict, uploads: list[tuple[str, bytes]],
) -> tuple[dict[str, bytes], list[str]]:
"""Pair each uploaded file with a meta path by base name (#46).
Returns ({relative path: bytes}, errors). Errors list every upload that
has no name or matches no meta path, and every unsafe meta path. A meta
path left without a file is not an error here: /validate decides.
"""
paths = collect_meta_paths(recipe.get("meta", {}))
by_name: dict[str, str] = {}
for p in paths:
by_name.setdefault(Path(p.replace("\\", "/")).name, p)
placed: dict[str, bytes] = {}
errors: list[str] = []
for name, content in uploads:
if not name:
errors.append("a file has no name")
continue
target = by_name.get(Path(name.replace("\\", "/")).name)
if target is None:
errors.append(f"file {name!r} matches no path in the recipe's meta")
continue
try:
_safe_join(Path("."), target)
except ValueError:
errors.append(f"meta path {target!r} is not allowed")
continue
placed[target] = content
return placed, errors
def store_recipe_folder(
task_id: int, recipe: dict, files: dict[str, bytes],
) -> str:
"""Write the immutable recipe folder; return its path relative to uploads.
Folder name = first 16 hex of the recipe's sha256. If it already exists it
is left untouched (immutable: other task versions may reference it).
"""
canonical = json.dumps(recipe, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
relative = f"vision/recipes/{task_id}/{digest}"
folder = settings.upload_path / relative
if folder.exists():
return relative
folder.mkdir(parents=True, exist_ok=True)
(folder / "recipe.json").write_text(
json.dumps(recipe, indent=2, ensure_ascii=False), encoding="utf-8",
)
for rel, content in files.items():
target = _safe_join(folder, rel)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
return relative
def list_recipe_files(recipe_dir: str | None) -> list[str]:
"""Files of a recipe folder, relative to it (recipe.json excluded)."""
if not recipe_dir:
return []
try:
folder = _safe_join(settings.upload_path, recipe_dir)
except ValueError:
return []
if not folder.is_dir():
return []
return sorted(
p.relative_to(folder).as_posix()
for p in folder.rglob("*") if p.is_file() and p.name != "recipe.json"
)
def save_result_files(
task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png",
) -> tuple[str, str]:
"""Save the executed image and its overlay JSON side by side (#46).
Returns (image_path, overlay_path), both relative to uploads.
"""
stem = uuid4().hex
base = Path("vision") / "results" / str(task_id)
(settings.upload_path / base).mkdir(parents=True, exist_ok=True)
image_rel = (base / f"{stem}.{image_ext}").as_posix()
overlay_rel = (base / f"{stem}.overlay.json").as_posix()
(settings.upload_path / image_rel).write_bytes(image_bytes)
(settings.upload_path / overlay_rel).write_text(json.dumps(overlay), encoding="utf-8")
return image_rel, overlay_rel
async def execute_task(
db: AsyncSession,
task: RecipeTask,
@@ -132,8 +320,9 @@ async def execute_task(
lot_number: str | None = None,
serial_number: str | None = None,
production_run_id: int | None = None,
) -> tuple[VisionResult, list[Measurement]]:
if task.task_type != "camera_measure":
image_ext: str = "png",
) -> tuple[VisionResult, list[Measurement], dict]:
if task.task_type not in VISION_TASK_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"task type {task.task_type!r} is not a camera measurement",
@@ -210,7 +399,11 @@ async def execute_task(
),
)
report = await _call_worker(image_bytes, task.vision_json)
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
# passa `calibration`, la ricetta porta la sua (#46).
report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
outputs = report["outputs"]
# Every quote or none. A half-measured piece read as a whole one is worse
@@ -231,8 +424,12 @@ async def execute_task(
),
)
overlay = report.get("overlay") or []
image_path, overlay_path = save_result_files(task.id, image_bytes, overlay, image_ext)
result = VisionResult(
task_id=task.id,
image_path=image_path,
overlay_path=overlay_path,
engine_version=report["engine_version"],
executed_on="server",
graph_snapshot=task.vision_json,
@@ -257,7 +454,7 @@ async def execute_task(
enforce_tolerance_gate=False,
))
return result, saved
return result, saved, report
def _reference_file_path(reference: VisionReferenceImage) -> Path:
@@ -377,4 +574,6 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph",
)
return await _call_worker(image_bytes, task.vision_json)
return await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
+37
View File
@@ -131,3 +131,40 @@ class TestApiKeyHashMigration:
command.upgrade(cfg, "head") # and up again
assert "api_keys" in _inspect(db_path).get_table_names()
class TestVisionRecipeDirMigration:
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
def test_016_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def cols():
return {c["name"] for c in _inspect(db_path).get_columns("recipe_tasks")}
command.upgrade(cfg, "head")
assert "vision_recipe_dir" in cols()
command.downgrade(cfg, "015_api_key_hash")
assert "vision_recipe_dir" not in cols()
command.upgrade(cfg, "head") # e di nuovo su
assert "vision_recipe_dir" in cols()
class TestUserPinMigration:
"""017_user_pin: users.pin_hash / pin_failed_count / pin_locked_until, su e giu (#59)."""
def test_017_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
new = {"pin_hash", "pin_failed_count", "pin_locked_until"}
def cols():
return {c["name"] for c in _inspect(db_path).get_columns("users")}
command.upgrade(cfg, "head")
assert new <= cols()
command.downgrade(cfg, "016_vision_recipe_dir")
assert not (new & cols())
command.upgrade(cfg, "head") # e di nuovo su
assert new <= cols()
+167
View File
@@ -0,0 +1,167 @@
"""PIN a 4 cifre: login rapido, blocco, capoturno via PIN (#59)."""
from datetime import timedelta
import pytest
from fastapi import HTTPException
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.user import User
from src.backend.services import auth_service
from src.backend.tests.conftest import _create_user, auth_headers
from src.backend.tests.test_production_runs import (
_open, _recipe_with_interval, _station,
)
from src.backend.utils.tempo import utcnow
async def _pin_user(db_session, username="operaio", pin="1234", **kw) -> User:
user = await _create_user(db_session, username=username, **kw)
await auth_service.set_user_pin(db_session, user, pin)
await db_session.commit()
return user
async def _login_pin(client, user_id, pin):
return await client.post("/api/auth/login-pin", json={"user_id": user_id, "pin": pin})
async def test_login_pin_ok_same_shape_as_login(client: AsyncClient, db_session):
user = await _pin_user(db_session)
resp = await _login_pin(client, user.id, "1234")
assert resp.status_code == 200
data = resp.json()
assert data["user"]["username"] == "operaio"
assert data["user"]["has_pin"] is True
assert data["api_key"]
assert "pin_hash" not in data["user"]
# la api_key vale davvero
me = await client.get("/api/auth/me", headers={"X-API-Key": data["api_key"]})
assert me.status_code == 200
async def test_login_pin_wrong_is_401(client: AsyncClient, db_session):
user = await _pin_user(db_session)
assert (await _login_pin(client, user.id, "9999")).status_code == 401
async def test_login_pin_format_422(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for bad in ("123", "12345", "12a4", ""):
assert (await _login_pin(client, user.id, bad)).status_code == 422
async def test_lock_after_five_errors_even_with_right_pin(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for _ in range(4):
assert (await _login_pin(client, user.id, "0000")).status_code == 401
resp = await _login_pin(client, user.id, "0000") # 5 errore: scatta il blocco
assert resp.status_code == 423
assert "locked until" in resp.json()["detail"]
assert (await _login_pin(client, user.id, "1234")).status_code == 423
async def test_lock_expires_and_counter_resets(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for _ in range(5):
await _login_pin(client, user.id, "0000")
row = (await db_session.execute(select(User).where(User.id == user.id))).scalar_one()
await db_session.refresh(row)
assert row.pin_locked_until > utcnow() + timedelta(minutes=14)
# tempo simulato: il blocco e scaduto un secondo fa
row.pin_locked_until = utcnow() - timedelta(seconds=1)
await db_session.commit()
assert (await _login_pin(client, user.id, "1234")).status_code == 200
await db_session.refresh(row)
assert row.pin_failed_count == 0 and row.pin_locked_until is None
async def test_success_resets_failed_counter(client: AsyncClient, db_session):
user = await _pin_user(db_session)
for _ in range(3):
await _login_pin(client, user.id, "0000")
assert (await _login_pin(client, user.id, "1234")).status_code == 200
for _ in range(4): # il contatore e ripartito da zero: 4 errori non bloccano
assert (await _login_pin(client, user.id, "0000")).status_code == 401
async def test_admin_cannot_have_pin_or_login_with_it(client: AsyncClient, admin_user, db_session):
resp = await client.put(
f"/api/users/{admin_user.id}/pin", headers=auth_headers(admin_user), json={"pin": "1234"},
)
assert resp.status_code == 422
# anche un admin con pin_hash scritto a mano non entra da login-pin
admin_user.pin_hash = auth_service.hash_password("1234")
await db_session.commit()
assert (await _login_pin(client, admin_user.id, "1234")).status_code == 401
listed = (await client.get("/api/auth/pin-users")).json()
assert all(u["id"] != admin_user.id for u in listed)
async def test_put_pin_set_and_remove(client: AsyncClient, admin_user, db_session):
user = await _create_user(db_session, username="senzapin")
await db_session.commit()
h = auth_headers(admin_user)
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "4321"})).status_code == 200
assert (await _login_pin(client, user.id, "4321")).status_code == 200
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": "12"})).status_code == 422
assert (await client.put(f"/api/users/{user.id}/pin", headers=h, json={"pin": None})).status_code == 200
assert (await _login_pin(client, user.id, "4321")).status_code == 401
assert (await client.put("/api/users/99999/pin", headers=h, json={"pin": "1234"})).status_code == 404
# non admin: 403
assert (await client.put(
f"/api/users/{user.id}/pin", headers=auth_headers(user), json={"pin": "1234"},
)).status_code == 403
async def test_pin_users_list(client: AsyncClient, db_session):
a = await _pin_user(db_session, username="zeta", display_name="Zeta Rossi", pin="1111")
b = await _pin_user(db_session, username="alfa", display_name="Alfa Bianchi", pin="2222")
await _create_user(db_session, username="nopin")
await _pin_user(db_session, username="inattivo", pin="3333", active=False)
await db_session.commit()
resp = await client.get("/api/auth/pin-users") # senza autenticazione
assert resp.status_code == 200
data = resp.json()
assert [u["display_name"] for u in data] == ["Alfa Bianchi", "Zeta Rossi"]
assert all(set(u) == {"id", "display_name"} for u in data)
assert {u["id"] for u in data} == {a.id, b.id}
async def test_supervisor_via_pin(client: AsyncClient, measurement_tec_user, admin_user, db_session):
station = await _station(db_session, admin_user.id, code="ST-PIN")
recipe = await _recipe_with_interval(db_session, admin_user.id, 10, code="REC-PIN")
run_id = (await _open(client, measurement_tec_user, station, recipe)).json()["id"]
capo = await _pin_user(db_session, username="capo-pin", pin="5555", roles=["Supervisor"])
h = auth_headers(measurement_tec_user)
bad = await client.post(
f"/api/production-runs/{run_id}/pause", headers=h,
json={"supervisor_user_id": capo.id, "supervisor_pin": "0000"},
)
assert bad.status_code == 401
ok = await client.post(
f"/api/production-runs/{run_id}/pause", headers=h,
json={"supervisor_user_id": capo.id, "supervisor_pin": "5555"},
)
assert ok.status_code == 200
# nessuna coppia di credenziali: 422
none = await client.post(f"/api/production-runs/{run_id}/resume", headers=h, json={})
assert none.status_code == 422
async def test_supervisor_pin_role_still_checked(db_session):
plain = await _pin_user(db_session, username="non-capo", pin="6666", roles=["Maker"])
with pytest.raises(HTTPException) as e:
await auth_service.authorise_supervisor(db_session, user_id=plain.id, pin="6666")
assert e.value.status_code == 403
async def test_supervisor_pin_lockout(db_session):
capo = await _pin_user(db_session, username="capo-lock", pin="7777", roles=["Supervisor"])
codes = []
for pin in ["0000"] * 5 + ["7777"]:
with pytest.raises(HTTPException) as e:
await auth_service.authorise_supervisor(db_session, user_id=capo.id, pin=pin)
codes.append(e.value.status_code)
assert codes == [401, 401, 401, 401, 423, 423]
-44
View File
@@ -176,50 +176,6 @@ class TestUpdateSettingsValidation:
)
assert resp.status_code == 200
# --- #47/#48/#49: chiavi Impostazioni di Tràfilo ---
@pytest.mark.parametrize("key,value", [
("default_measurement_interval_minutes", "1"),
("default_measurement_interval_minutes", "1440"),
("default_measurement_interval_minutes", ""),
("general_tolerance_plus", "0"),
("general_tolerance_plus", "0.05"),
("general_tolerance_minus", ""),
("list_measurement_types", '["Lunghezza", "Diametro"]'),
("list_units", "[]"),
("list_units", ""),
])
async def test_new_keys_valid_accepted(
self, client: AsyncClient, admin_user: User, key: str, value: str
):
resp = await client.put(
"/api/settings/", headers=auth_headers(admin_user), json={key: value}
)
assert resp.status_code == 200, resp.text
@pytest.mark.parametrize("key,value", [
("default_measurement_interval_minutes", "0"),
("default_measurement_interval_minutes", "1441"),
("default_measurement_interval_minutes", "abc"),
("general_tolerance_plus", "-0.1"),
("general_tolerance_plus", "0,05"),
("general_tolerance_minus", "NaN"),
("general_tolerance_minus", "abc"),
("list_measurement_types", "not json"),
("list_measurement_types", '{"a": 1}'),
("list_units", '["mm", ""]'),
("list_units", '["mm", 3]'),
("list_units", "[" + ",".join(f'"v{i}"' for i in range(51)) + "]"),
])
async def test_new_keys_invalid_rejected(
self, client: AsyncClient, admin_user: User, key: str, value: str
):
resp = await client.put(
"/api/settings/", headers=auth_headers(admin_user), json={key: value}
)
assert resp.status_code == 422, resp.text
assert key in resp.json()["detail"]
async def test_unknown_key_not_validated(
self, client: AsyncClient, admin_user: User
):
+2 -2
View File
@@ -66,7 +66,7 @@ async def two_vision_quotes(db_session, recipe_id: int):
def worker_says(monkeypatch):
"""The worker does not run in tests: its response is faked here."""
def _install(outputs: dict, failures: list | None = None):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return {
"outputs": outputs,
"failures": failures or [],
@@ -316,7 +316,7 @@ async def test_a_pending_fail_blocks_before_the_worker_is_called(
)
await db_session.commit()
async def worker_must_not_be_called(image_bytes: bytes, graph: dict) -> dict:
async def worker_must_not_be_called(image_bytes: bytes, graph: dict, **kwargs) -> dict:
raise AssertionError("the worker must not run while the operator is blocked")
monkeypatch.setattr(
"src.backend.services.vision_service._call_worker", worker_must_not_be_called,
@@ -53,7 +53,7 @@ async def a_camera_task(db_session, recipe_id: int):
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return {"outputs": outputs, "failures": [],
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr(
@@ -0,0 +1,265 @@
"""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
+1 -1
View File
@@ -39,7 +39,7 @@ async def recipe_parts(db_session, recipe_id: int):
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return {"outputs": outputs, "failures": [],
"engine_version": "b" * 40, "duration_ms": 7}
monkeypatch.setattr(
@@ -69,7 +69,7 @@ async def recipe_parts(db_session, recipe_id: int):
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
return {"outputs": outputs, "failures": [],
"engine_version": "c" * 40, "duration_ms": 5}
monkeypatch.setattr(
+15 -17
View File
@@ -29,30 +29,28 @@ def admin_required(f):
@login_required
@admin_required
def user_list():
"""#50: la gestione utenti vive nella tab «Utenti» di Impostazioni."""
return redirect(url_for("admin.settings_page", tab="utenti"))
"""User management page."""
resp = api_client.get("/api/users")
if isinstance(resp, dict) and resp.get("error"):
flash(_("Errore nel caricamento degli utenti: %(error)s", error=resp.get("detail", "")), "error")
users = []
elif isinstance(resp, list):
users = resp
else:
users = []
return render_template("admin/users.html", users=users)
@admin_bp.route("/settings")
@login_required
@admin_required
def settings_page():
"""System settings page (admin only), con la tab «Utenti» (#50)."""
"""System settings page."""
resp = api_client.get("/api/settings")
# Solo un dict valido sono impostazioni: un errore o altro tipo = nessuna
settings = resp if isinstance(resp, dict) and not resp.get("error") else {}
users_resp = api_client.get("/api/users")
if isinstance(users_resp, dict) and users_resp.get("error"):
flash(_("Errore nel caricamento degli utenti: %(error)s", error=users_resp.get("detail", "")), "error")
users = []
elif isinstance(users_resp, list):
users = users_resp
else:
users = []
tab = "utenti" if request.args.get("tab") == "utenti" else "generale"
return render_template("admin/settings.html", settings=settings, users=users, active_tab=tab)
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
return render_template("admin/settings.html", settings=settings)
@admin_bp.route("/api/settings", methods=["PUT"])
@@ -1,258 +0,0 @@
<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,31 +3,17 @@
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
{% block content %}
<div class="tmf-page" x-data="{ tab: {{ active_tab|tojson_attr }} }">
<div class="tmf-page tmf-page-narrow"
x-data="systemSettings()">
<!-- Header -->
<div class="mb-4">
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
</div>
<!-- #50: tab. L'intera pagina e' gia' solo per admin (admin_required) -->
<div class="flex gap-1 mb-6 border-b border-[var(--border-color)]">
<button type="button" @click="tab = 'generale'"
:class="tab === 'generale' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Generale') }}</button>
<button type="button" @click="tab = 'utenti'"
:class="tab === 'utenti' ? 'border-primary text-primary' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'"
class="px-4 py-2 -mb-px text-sm font-medium border-b-2 transition-colors">{{ _('Utenti') }}</button>
</div>
<div x-show="tab === 'utenti'" x-cloak>
{% include "admin/_users_panel.html" %}
</div>
<div x-show="tab === 'generale'" class="max-w-4xl mx-auto" x-data="systemSettings()">
<!-- Settings Card -->
<div class="tmf-card mb-6">
<div class="tmf-card">
<div class="tmf-card-header flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
@@ -57,57 +43,6 @@
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
</p>
</div>
<!-- #47: durata loop predefinita delle ricette -->
<div class="pt-5 border-t border-[var(--border-color)]">
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Ricette') }}</h3>
<label class="tmf-label" for="default-interval">{{ _('Durata loop predefinita (minuti)') }}</label>
<input id="default-interval" type="number" x-model="defaultInterval" class="tmf-input w-32" min="1" max="1440" placeholder="—">
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Proposta come intervallo di misura nelle nuove ricette (1-1440). Vuoto = nessun predefinito.') }}</p>
</div>
<!-- #48: tolleranze generali -->
<div class="pt-5 border-t border-[var(--border-color)]">
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Tolleranze generali') }}</h3>
<div class="flex items-center gap-6">
<div>
<label class="tmf-label" for="tol-plus">{{ _('Tolleranza +') }}</label>
<input id="tol-plus" type="text" inputmode="decimal" x-model="tolPlus" class="tmf-input w-32" placeholder="—">
</div>
<div>
<label class="tmf-label" for="tol-minus">{{ _('Tolleranza −') }}</label>
<input id="tol-minus" type="text" inputmode="decimal" x-model="tolMinus" class="tmf-input w-32" placeholder="—">
</div>
</div>
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Valori positivi (zero o più) proposti come tolleranza nelle nuove misure. Vuoto = nessun predefinito.') }}</p>
</div>
<!-- #49: anagrafiche (liste di scelta) -->
<div class="pt-5 border-t border-[var(--border-color)]">
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-3">{{ _('Anagrafiche') }}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<template x-for="lst in ['types', 'units']" :key="lst">
<div>
<label class="tmf-label" x-text="lst === 'types' ? {{ _('Tipi di misura')|tojson_attr }} : {{ _('Unità di misura')|tojson_attr }}"></label>
<ul class="space-y-1 mb-2">
<template x-for="(item, i) in lists[lst]" :key="lst + i">
<li class="flex items-center gap-1 text-sm">
<span class="flex-1 px-2 py-1 rounded bg-[var(--bg-secondary)] text-[var(--text-primary)]" x-text="item"></span>
<button type="button" @click="moveItem(lst, i, -1)" :disabled="i === 0" class="px-1.5 text-[var(--text-muted)] hover:text-primary disabled:opacity-30" title="{{ _('Su') }}">▲</button>
<button type="button" @click="moveItem(lst, i, 1)" :disabled="i === lists[lst].length - 1" class="px-1.5 text-[var(--text-muted)] hover:text-primary disabled:opacity-30" title="{{ _('Giù') }}">▼</button>
<button type="button" @click="removeItem(lst, i)" class="px-1.5 text-[var(--text-muted)] hover:text-red-500" title="{{ _('Rimuovi') }}">✕</button>
</li>
</template>
</ul>
<div class="flex gap-2">
<input type="text" x-model="newItem[lst]" @keydown.enter.prevent="addItem(lst)" class="tmf-input flex-1" placeholder="{{ _('Nuova voce') }}">
<button type="button" @click="addItem(lst)" class="btn btn-secondary">{{ _('Aggiungi') }}</button>
</div>
</div>
</template>
</div>
<p class="text-xs text-[var(--text-muted)] mt-2">{{ _('Massimo 50 voci per lista. Un elenco vuoto = nessuna scelta predefinita.') }}</p>
</div>
</div>
<div class="tmf-card-footer flex items-center justify-between">
@@ -133,48 +68,18 @@
</button>
</div>
</div>
</div><!-- /tab generale -->
</div>
{% endblock %}
{% block extra_js %}
<script>
// #49: legge un array JSON di stringhe salvato come impostazione; vuoto/malformato = lista vuota
function tmfParseList(raw) {
try { var a = JSON.parse(raw || '[]'); return Array.isArray(a) ? a.map(String) : []; }
catch (e) { return []; }
}
function systemSettings() {
return {
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
// #47-#49: nuove chiavi (valori stringa, vuoto = nessun default)
defaultInterval: {{ (settings.default_measurement_interval_minutes or '')|tojson }},
tolPlus: {{ (settings.general_tolerance_plus or '')|tojson }},
tolMinus: {{ (settings.general_tolerance_minus or '')|tojson }},
lists: {
types: tmfParseList({{ (settings.list_measurement_types or '')|tojson }}),
units: tmfParseList({{ (settings.list_units or '')|tojson }})
},
newItem: { types: '', units: '' },
saving: false,
saved: false,
errorMessage: '',
addItem(lst) {
var v = (this.newItem[lst] || '').trim();
if (!v) return;
if (this.lists[lst].length >= 50) { this.errorMessage = {{ _('Massimo 50 voci per lista')|tojson }}; return; }
if (this.lists[lst].indexOf(v) === -1) this.lists[lst].push(v);
this.newItem[lst] = '';
},
removeItem(lst, i) { this.lists[lst].splice(i, 1); },
moveItem(lst, i, d) {
var a = this.lists[lst].slice(), j = i + d;
if (j < 0 || j >= a.length) return;
var t = a[i]; a[i] = a[j]; a[j] = t;
this.lists[lst] = a;
},
async save() {
this.saving = true;
this.saved = false;
@@ -182,13 +87,7 @@ function systemSettings() {
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
var payload = {
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) : ''
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : ''
};
try {
@@ -212,5 +111,4 @@ function systemSettings() {
};
}
</script>
{% include "admin/_users_script.html" %}
{% endblock %}
@@ -1,7 +1,18 @@
{# Pannello utenti: incluso nella tab «Utenti» di Impostazioni (#50) #}
{% extends "base.html" %}
{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %}
{% block content %}
<script>window.__users = {{ users|tojson }};</script>
<div x-data="userManagement(window.__users)">
<div class="tmf-page"
x-data="userManagement(window.__users)">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Utenti') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci gli utenti del sistema') }}</p>
</div>
<!-- Toolbar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
@@ -91,12 +102,6 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<!-- #51: reset password dall'admin -->
<button @click="openReset(user)" class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="{{ _('Reset password')|tojson_attr }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
</button>
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
:class="user.active
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
@@ -327,51 +332,210 @@
</div>
</div>
<!-- #51: Modal reset password (la password attuale non si mostra mai) -->
<div x-show="showReset" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@keydown.escape.window="closeReset()">
<div class="absolute inset-0 bg-black/50" @click="closeReset()"></div>
<div class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
{{ _('Reset password') }}: <span x-text="resetUser?.username"></span>
</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Le sessioni aperte dell\'utente verranno chiuse e dovrà accedere con la nuova password.') }}
</p>
<template x-if="!resetDone">
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nuova password (minimo 8 caratteri)') }}</label>
<div class="flex gap-2">
<input type="text" x-model="resetPw" autocomplete="off"
class="flex-1 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary">
<button @click="generateResetPw()" type="button"
class="px-3 py-2 text-sm font-medium rounded-lg border border-[var(--border-color)] text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
{{ _('Genera') }}
</button>
</div>
<template x-if="resetError">
<p class="mt-2 text-sm text-red-600" x-text="resetError"></p>
</template>
</div>
</template>
<template x-if="resetDone">
<div class="px-3 py-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
<p class="text-sm text-emerald-700 dark:text-emerald-300">{{ _('Password cambiata. Annotala ora: non verrà mostrata di nuovo.') }}</p>
<p class="mt-1 text-base font-mono font-semibold text-[var(--text-primary)] select-all" x-text="resetPw"></p>
</div>
</template>
<div class="flex justify-end gap-3 mt-5">
<button @click="closeReset()"
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)]">
<span x-text="resetDone ? {{ _('Chiudi')|tojson_attr }} : {{ _('Annulla')|tojson_attr }}"></span>
</button>
<button x-show="!resetDone" @click="confirmReset()" :disabled="saving"
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-700 shadow-sm disabled:opacity-50">
{{ _('Imposta password') }}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function userManagement(initialUsers) {
return {
users: initialUsers || [],
search: '',
showModal: false,
showToggleConfirm: false,
isEditing: false,
editingUserId: null,
toggleUser: null,
saving: false,
errorMsg: '',
form: {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
},
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
return meta ? meta.content : '';
},
get filteredUsers() {
if (!this.search.trim()) return this.users;
const q = this.search.toLowerCase();
return this.users.filter(u =>
u.username.toLowerCase().includes(q) ||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
(u.email && u.email.toLowerCase().includes(q)) ||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
);
},
openCreateModal() {
this.isEditing = false;
this.editingUserId = null;
this.errorMsg = '';
this.form = {
username: '',
display_name: '',
email: '',
password: '',
roles: [],
is_admin: false,
language_pref: 'it',
theme_pref: 'light'
};
this.showModal = true;
},
openEditModal(user) {
this.isEditing = true;
this.editingUserId = user.id;
this.errorMsg = '';
this.form = {
username: user.username,
display_name: user.display_name || '',
email: user.email || '',
password: '',
roles: [...(user.roles || [])],
is_admin: user.is_admin || false,
language_pref: user.language_pref || 'it',
theme_pref: user.theme_pref || 'light'
};
this.showModal = true;
},
closeModal() {
this.showModal = false;
this.errorMsg = '';
},
async saveUser() {
this.saving = true;
this.errorMsg = '';
try {
if (this.isEditing) {
// Update user
const data = {
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nel salvataggio';
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.editingUserId);
if (idx >= 0) this.users[idx] = result;
// Change password if provided
if (this.form.password) {
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ password: this.form.password })
});
if (!pwResp.ok) {
const pwResult = await pwResp.json();
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
return;
}
}
} else {
// Create user
if (!this.form.username || !this.form.display_name || !this.form.password) {
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
return;
}
const data = {
username: this.form.username,
password: this.form.password,
display_name: this.form.display_name,
email: this.form.email || null,
roles: this.form.roles,
is_admin: this.form.is_admin,
language_pref: this.form.language_pref,
theme_pref: this.form.theme_pref
};
const resp = await fetch('/admin/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify(data)
});
const result = await resp.json();
if (!resp.ok) {
this.errorMsg = result.detail || 'Errore nella creazione';
return;
}
this.users.push(result);
}
this.closeModal();
} catch (e) {
this.errorMsg = '{{ _("Errore di connessione al server") }}';
} finally {
this.saving = false;
}
},
toggleActive(user) {
this.toggleUser = user;
this.showToggleConfirm = true;
},
async confirmToggle() {
if (!this.toggleUser) return;
this.saving = true;
try {
const newActive = !this.toggleUser.active;
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
body: JSON.stringify({ active: newActive })
});
const result = await resp.json();
if (!resp.ok) {
alert(result.detail || 'Errore');
return;
}
// Update local array
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
if (idx >= 0) this.users[idx] = result;
this.showToggleConfirm = false;
this.toggleUser = null;
} catch (e) {
alert('{{ _("Errore di connessione al server") }}');
} finally {
this.saving = false;
}
}
};
}
</script>
{% endblock %}
@@ -32,6 +32,7 @@
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
{% endif %}
{% if current_user.get('is_admin') %}
{{ nav_link(url_for('admin.user_list'), _('Utenti'), icon_users, ep == 'admin.user_list', mobile) }}
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }}
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
{% endif %}
@@ -1,64 +0,0 @@
"""#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"})
@@ -256,7 +256,7 @@ def test_admin_stations_inline_js_is_valid_in_english(
def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
"""Same guard for the Users tab, now inside /admin/settings (#50)."""
"""Same guard for /admin/users (reuses the userManagement Alpine pattern)."""
mock_admin_api.get.return_value = [{
"id": 1, "username": "admin", "display_name": "Admin",
"email": None, "roles": ["Maker"], "is_admin": True,
@@ -264,7 +264,7 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
}]
_force_italian(logged_in_client)
resp = logged_in_client.get("/admin/settings")
resp = logged_in_client.get("/admin/users")
assert resp.status_code == 200
html = resp.data.decode("utf-8")
@@ -272,9 +272,9 @@ def test_admin_users_inline_js_is_valid(logged_in_client, mock_admin_api):
assert scripts
for i, body in enumerate(scripts):
_node_check(body, f"/admin/settings script[{i}]")
_node_check(body, f"/admin/users script[{i}]")
_check_alpine_attributes(html, "/admin/settings")
_check_alpine_attributes(html, "/admin/users")
@pytest.fixture