Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07e23b957d | |||
| 86290274e5 | |||
| ee0f65ed39 | |||
| 18d0db8a0c |
@@ -1,5 +1,6 @@
|
|||||||
"""Authentication router - login, logout, profile."""
|
"""Authentication router - login, logout, profile."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
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
|
||||||
@@ -8,10 +9,17 @@ from src.backend.models.orm.user import User
|
|||||||
from src.backend.models.api.user import (
|
from src.backend.models.api.user import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
PinLoginRequest,
|
||||||
|
PinUserItem,
|
||||||
UserProfileUpdate,
|
UserProfileUpdate,
|
||||||
UserResponse,
|
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"])
|
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)
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def logout(
|
async def logout(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ async def authorise_measurement(
|
|||||||
|
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
db, action.supervisor_username, action.supervisor_password,
|
||||||
|
user_id=action.supervisor_user_id, pin=action.supervisor_pin, # #59
|
||||||
)
|
)
|
||||||
measurement = await measurement_service.authorise_measurement(
|
measurement = await measurement_service.authorise_measurement(
|
||||||
db, measurement, supervisor,
|
db, measurement, supervisor,
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ async def pause_production_run(
|
|||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
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)
|
run = await production_service.pause_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
return await _as_response(db, run)
|
||||||
@@ -157,6 +158,7 @@ async def resume_production_run(
|
|||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
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)
|
run = await production_service.resume_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
return await _as_response(db, run)
|
||||||
@@ -173,6 +175,7 @@ async def close_production_run(
|
|||||||
run = await production_service.get_run(db, run_id)
|
run = await production_service.get_run(db, run_id)
|
||||||
supervisor = await auth_service.authorise_supervisor(
|
supervisor = await auth_service.authorise_supervisor(
|
||||||
db, action.supervisor_username, action.supervisor_password,
|
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)
|
run = await production_service.close_run(db, run, user, supervisor, note=action.note)
|
||||||
return await _as_response(db, run)
|
return await _as_response(db, run)
|
||||||
|
|||||||
@@ -6,8 +6,14 @@ 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_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 PinSet, UserCreate, UserPasswordChange, UserResponse, UserUpdate
|
||||||
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys
|
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"])
|
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}"}
|
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)
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def deactivate_user(
|
async def deactivate_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -5,19 +5,25 @@ the numbers: that is what lets a station's configuration change without touching
|
|||||||
the frontend or the recipes.
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
|
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, model_validator
|
||||||
|
|
||||||
|
|
||||||
class MeasurementCreate(BaseModel):
|
class MeasurementCreate(BaseModel):
|
||||||
@@ -71,8 +71,21 @@ class MeasurementListResponse(BaseModel):
|
|||||||
class MeasurementAuthorisation(BaseModel):
|
class MeasurementAuthorisation(BaseModel):
|
||||||
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
"""Credentials of the supervisor allowing an out-of-tolerance value to stand."""
|
||||||
|
|
||||||
supervisor_username: str = Field(..., min_length=1)
|
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||||
supervisor_password: str = Field(..., min_length=1)
|
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):
|
class TaskProgressResponse(BaseModel):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from src.backend.models.api.tipi import UtcDateTime
|
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, model_validator
|
||||||
|
|
||||||
|
|
||||||
class ProductionRunCreate(BaseModel):
|
class ProductionRunCreate(BaseModel):
|
||||||
@@ -64,8 +64,21 @@ class ProductionRunWithEventsResponse(ProductionRunResponse):
|
|||||||
class SupervisorAction(BaseModel):
|
class SupervisorAction(BaseModel):
|
||||||
"""Credentials of the supervisor authorising a line stop or a close."""
|
"""Credentials of the supervisor authorising a line stop or a close."""
|
||||||
|
|
||||||
supervisor_username: str = Field(..., min_length=1)
|
# #59: o username+password, oppure supervisor_user_id+supervisor_pin
|
||||||
supervisor_password: str = Field(..., min_length=1)
|
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
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class UserResponse(BaseModel):
|
|||||||
active: bool
|
active: bool
|
||||||
created_at: UtcDateTime
|
created_at: UtcDateTime
|
||||||
last_login: Optional[UtcDateTime] = None
|
last_login: Optional[UtcDateTime] = None
|
||||||
|
# #59: solo il fatto che il PIN esista, mai l'hash
|
||||||
|
has_pin: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserPasswordChange(BaseModel):
|
class UserPasswordChange(BaseModel):
|
||||||
@@ -68,3 +70,21 @@ class LoginResponse(BaseModel):
|
|||||||
user: UserResponse
|
user: UserResponse
|
||||||
api_key: str
|
api_key: str
|
||||||
message: str = "Login successful"
|
message: str = "Login successful"
|
||||||
|
|
||||||
|
|
||||||
|
class PinUserItem(BaseModel):
|
||||||
|
"""Voce dell'elenco pubblico per il login con PIN (#59): solo id e nome."""
|
||||||
|
id: int
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class PinLoginRequest(BaseModel):
|
||||||
|
"""Login con PIN (#59)."""
|
||||||
|
user_id: int
|
||||||
|
pin: str = Field(..., pattern=r"^[0-9]{4}$")
|
||||||
|
|
||||||
|
|
||||||
|
class PinSet(BaseModel):
|
||||||
|
"""Imposta (4 cifre) o toglie (null) il PIN di un utente (#59)."""
|
||||||
|
pin: Optional[str] = Field(None, pattern=r"^[0-9]{4}$")
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,34 @@ class VisionExecuteResponse(BaseModel):
|
|||||||
vision_result_id: int
|
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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ class User(Base):
|
|||||||
# Admin flag separate from roles
|
# Admin flag separate from roles
|
||||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
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
|
# User preferences
|
||||||
language_pref: Mapped[str] = mapped_column(
|
language_pref: Mapped[str] = mapped_column(
|
||||||
Enum("it", "en", name="language_enum"), nullable=False, default="it"
|
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)
|
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:
|
def has_role(self, role: str) -> bool:
|
||||||
"""Check if user has a specific role."""
|
"""Check if user has a specific role."""
|
||||||
return role in (self.roles or [])
|
return role in (self.roles or [])
|
||||||
|
|||||||
@@ -54,7 +54,79 @@ async def authenticate_user(
|
|||||||
return 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.
|
"""Check the credentials of the supervisor authorising something.
|
||||||
|
|
||||||
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
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
|
Uses authenticate_user rather than a full login: logging in would issue a
|
||||||
needless API key for the supervisor.
|
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:
|
if supervisor is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,180 @@ 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=(",", ":"))
|
||||||
|
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(
|
async def execute_task(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
task: RecipeTask,
|
task: RecipeTask,
|
||||||
@@ -132,8 +320,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 +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"]
|
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 +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(
|
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 +454,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 +574,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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -131,3 +131,40 @@ class TestApiKeyHashMigration:
|
|||||||
|
|
||||||
command.upgrade(cfg, "head") # and up again
|
command.upgrade(cfg, "head") # and up again
|
||||||
assert "api_keys" in _inspect(db_path).get_table_names()
|
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()
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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,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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"""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, Response, abort, flash, jsonify, redirect, render_template,
|
Blueprint, abort, flash, jsonify, redirect, render_template,
|
||||||
request, session, url_for,
|
request, session, url_for,
|
||||||
)
|
)
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
@@ -822,67 +820,3 @@ 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"),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -191,8 +191,7 @@
|
|||||||
{# ================================================================
|
{# ================================================================
|
||||||
MAIN — 3-column layout
|
MAIN — 3-column layout
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
{# #46: camera_measure / dxf_compare get the acquisition panel below instead of the manual layout #}
|
<div class="flex-1 flex overflow-hidden">
|
||||||
<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)
|
||||||
@@ -452,63 +451,6 @@
|
|||||||
|
|
||||||
</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
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
@@ -970,18 +912,6 @@
|
|||||||
<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>
|
||||||
@@ -1846,12 +1776,6 @@ 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() {
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
"""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"
|
|
||||||
Reference in New Issue
Block a user