Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad6ce5d11d |
@@ -4,8 +4,11 @@ It does not know, and must not know, whether the server or the station computed
|
||||
the numbers: that is what lets a station's configuration change without touching
|
||||
the frontend or the recipes.
|
||||
"""
|
||||
import base64
|
||||
import binascii
|
||||
import io
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
@@ -26,7 +29,7 @@ from src.backend.models.api.vision import (
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.models.orm.task import RecipeTask
|
||||
from src.backend.models.orm.user import User
|
||||
from src.backend.services import vision_service
|
||||
from src.backend.services import station_service, vision_service
|
||||
|
||||
router = APIRouter(prefix="/api/vision", tags=["vision"])
|
||||
|
||||
@@ -185,6 +188,30 @@ def _422(errors: list[str]) -> HTTPException:
|
||||
)
|
||||
|
||||
|
||||
async def _store_validate_assign(
|
||||
task: RecipeTask, graph: dict, placed: dict[str, bytes],
|
||||
) -> tuple[str, dict]:
|
||||
"""Scrive la cartella ricetta, la fa validare dal worker, la assegna al task.
|
||||
|
||||
Condiviso da upload_recipe e dxf-compare con save (#83): stesso
|
||||
comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check).
|
||||
"""
|
||||
# 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
|
||||
return recipe_dir, check
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
|
||||
async def upload_recipe(
|
||||
task_id: int,
|
||||
@@ -218,19 +245,7 @@ async def upload_recipe(
|
||||
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
|
||||
recipe_dir, check = await _store_validate_assign(task, graph, placed)
|
||||
await db.commit()
|
||||
return VisionRecipeUploadResponse(
|
||||
task_id=task.id,
|
||||
@@ -325,3 +340,77 @@ async def preview(
|
||||
) -> dict:
|
||||
task = await _task_or_404(db, task_id)
|
||||
return await vision_service.preview(db, task, await _read_image(image))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/recipe/dxf-compare")
|
||||
async def create_dxf_compare_recipe(
|
||||
task_id: int,
|
||||
image: UploadFile = File(...),
|
||||
dxf: UploadFile = File(...),
|
||||
station_id: int | None = Form(None, gt=0),
|
||||
px_per_mm: float | None = Form(None, gt=0),
|
||||
band_mm: float = Form(0.1, gt=0),
|
||||
min_points_pct: float = Form(95, ge=0, le=100),
|
||||
polarity: Literal["dark", "light"] = Form("dark"),
|
||||
save: bool = Form(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_maker),
|
||||
) -> dict:
|
||||
"""Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83).
|
||||
|
||||
La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore).
|
||||
Con save=false si vede il risultato e non si scrive nulla; con save=true la
|
||||
ricetta va sul task come con l'upload, e se il task non ha nessuna quota di
|
||||
visione nasce la quota «Punti in tolleranza».
|
||||
"""
|
||||
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"])
|
||||
|
||||
station = None
|
||||
if station_id is not None:
|
||||
station = await station_service.get_station(db, station_id) # 404 se non esiste
|
||||
if px_per_mm is None:
|
||||
calibration, reason = await vision_service.fetch_station_calibration(station)
|
||||
if calibration is None:
|
||||
# irraggiungibile e non calibrata sono due guasti diversi per chi lo legge
|
||||
raise _422([
|
||||
"acquisitore della stazione non raggiungibile"
|
||||
if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata"
|
||||
])
|
||||
px_per_mm = float(calibration["px_per_mm"])
|
||||
|
||||
image_bytes = await _read_image(image)
|
||||
dxf_bytes = await dxf.read()
|
||||
if not validate_file_size(len(dxf_bytes)):
|
||||
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
|
||||
|
||||
body = await vision_service.dxf_compare_recipe(
|
||||
image_bytes, dxf_bytes,
|
||||
px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct,
|
||||
polarity=polarity,
|
||||
worker_url=(station.vision_worker_url or None) if station is not None else None,
|
||||
)
|
||||
# i file stanno nella ricetta salvata, non nella risposta al client
|
||||
files = body.pop("files")
|
||||
response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None}
|
||||
if not save:
|
||||
return response
|
||||
|
||||
uploads: list[tuple[str, bytes]] = []
|
||||
for name, encoded in files.items():
|
||||
try:
|
||||
uploads.append((name, base64.b64decode(encoded, validate=True)))
|
||||
except (binascii.Error, ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker's answer was malformed: file {name!r} is not base64",
|
||||
)
|
||||
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
|
||||
if errors:
|
||||
raise _422(errors)
|
||||
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
|
||||
await vision_service.ensure_points_quote(db, task, min_points_pct)
|
||||
await db.commit()
|
||||
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
|
||||
return response
|
||||
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
@@ -24,6 +25,8 @@ from src.backend.models.orm.task import RecipeSubtask, RecipeTask
|
||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||
from src.backend.services import measurement_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Worker responses whose status and reason are forwarded as-is rather than
|
||||
# flattened into a generic 502 - see `_call_worker`.
|
||||
_FORWARDED_WORKER_STATUSES = frozenset({
|
||||
@@ -140,6 +143,136 @@ async def _call_worker(
|
||||
return report
|
||||
|
||||
|
||||
async def call_worker_multipart(
|
||||
path: str,
|
||||
*,
|
||||
files: dict,
|
||||
data: dict,
|
||||
worker_url: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> dict:
|
||||
"""POST multipart generico al worker (#83): /recipes/dxf-compare.
|
||||
|
||||
Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul
|
||||
trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si
|
||||
inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale.
|
||||
"""
|
||||
base = (worker_url or settings.vision_worker_url).rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(f"{base}{path}", files=files, 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} ({base})",
|
||||
) from exc
|
||||
except (httpx.TransportError, httpx.HTTPError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the vision worker is unreachable: {exc} ({base})",
|
||||
) from exc
|
||||
|
||||
if response.status_code in _FORWARDED_WORKER_STATUSES:
|
||||
try:
|
||||
detail = response.json().get("detail", response.text)
|
||||
except ValueError:
|
||||
detail = response.text
|
||||
raise HTTPException(status_code=response.status_code, detail=detail)
|
||||
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=f"the vision worker's answer was malformed: not valid JSON ({exc})",
|
||||
) from exc
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: expected a JSON object",
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
async def dxf_compare_recipe(
|
||||
image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float,
|
||||
min_points_pct: float, polarity: str, worker_url: str | None = None,
|
||||
) -> dict:
|
||||
"""W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test."""
|
||||
body = await call_worker_multipart(
|
||||
"/recipes/dxf-compare",
|
||||
files={
|
||||
"image": ("frame.png", image_bytes, "image/png"),
|
||||
"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"),
|
||||
},
|
||||
data={
|
||||
"px_per_mm": str(px_per_mm), "band_mm": str(band_mm),
|
||||
"min_points_pct": str(min_points_pct), "polarity": polarity,
|
||||
},
|
||||
worker_url=worker_url,
|
||||
)
|
||||
# forma minima del contratto W2: senza graph e files non c'e' nulla da salvare
|
||||
if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="the vision worker's answer was malformed: missing graph or files",
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
# Stati di fetch_station_calibration (#83)
|
||||
CAL_OK = "ok"
|
||||
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
|
||||
CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile)
|
||||
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
|
||||
|
||||
|
||||
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
|
||||
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
|
||||
|
||||
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
|
||||
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
|
||||
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
|
||||
Un px_per_mm non finito o <= 0 conta come non calibrata.
|
||||
"""
|
||||
if station is None or not station.camera_url or not station.camera_source:
|
||||
return None, CAL_NO_CAMERA
|
||||
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
|
||||
try:
|
||||
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(url, params={"source": station.camera_source})
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
|
||||
return None, CAL_UNREACHABLE
|
||||
if response.status_code == status.HTTP_404_NOT_FOUND:
|
||||
return None, CAL_NOT_CALIBRATED
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
|
||||
)
|
||||
return None, CAL_UNREACHABLE
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
|
||||
return None, CAL_UNREACHABLE
|
||||
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
|
||||
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
|
||||
return None, CAL_NOT_CALIBRATED
|
||||
return body, CAL_OK
|
||||
|
||||
|
||||
async def get_station_calibration(station: Station | None) -> dict | None:
|
||||
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
|
||||
calibration, _ = await fetch_station_calibration(station)
|
||||
return calibration
|
||||
|
||||
|
||||
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
|
||||
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
|
||||
|
||||
@@ -365,11 +498,14 @@ async def execute_task(
|
||||
)
|
||||
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
|
||||
station_url = None
|
||||
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
|
||||
calibration = None
|
||||
if run is not None:
|
||||
station = (await db.execute(
|
||||
select(Station).where(Station.id == run.station_id)
|
||||
)).scalar_one_or_none()
|
||||
station_url = (station.vision_worker_url or None) if station is not None else None
|
||||
calibration = await get_station_calibration(station)
|
||||
|
||||
if not task.vision_json:
|
||||
raise HTTPException(
|
||||
@@ -419,11 +555,15 @@ async def execute_task(
|
||||
),
|
||||
)
|
||||
|
||||
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
|
||||
# passa `calibration`, la ricetta porta la sua (#46).
|
||||
# La calibrazione viene dall'acquisitore della stazione (#83); se non c'e',
|
||||
# non si passa `calibration` e la ricetta porta la sua (#46).
|
||||
worker_kwargs: dict = {}
|
||||
if calibration is not None:
|
||||
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
|
||||
report = await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
worker_url=station_url, # #45
|
||||
**worker_kwargs,
|
||||
)
|
||||
outputs = report["outputs"]
|
||||
|
||||
@@ -457,6 +597,8 @@ async def execute_task(
|
||||
station_id=run.station_id if run is not None else None,
|
||||
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
|
||||
graph_snapshot=task.vision_json,
|
||||
# copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato
|
||||
calibration_snapshot=calibration,
|
||||
duration_ms=report.get("duration_ms"),
|
||||
)
|
||||
db.add(result)
|
||||
@@ -601,3 +743,42 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
|
||||
return await _call_worker(
|
||||
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_points_quote(
|
||||
db: AsyncSession, task: RecipeTask, min_points_pct: float,
|
||||
) -> bool:
|
||||
"""Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83).
|
||||
|
||||
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
|
||||
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
|
||||
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
|
||||
"""
|
||||
from src.backend.services import recipe_service # import locale: evita cicli
|
||||
|
||||
existing = (await db.execute(
|
||||
select(RecipeSubtask.id)
|
||||
.where(RecipeSubtask.task_id == task.id)
|
||||
.where(RecipeSubtask.vision_output.is_not(None))
|
||||
.limit(1)
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return False
|
||||
if await recipe_service.version_has_measurements(db, task.version_id):
|
||||
return False
|
||||
markers = (await db.execute(
|
||||
select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id)
|
||||
)).scalars().all()
|
||||
db.add(RecipeSubtask(
|
||||
task_id=task.id,
|
||||
marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker)
|
||||
description="Punti in tolleranza",
|
||||
nominal=100,
|
||||
# limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct
|
||||
ltl=min_points_pct,
|
||||
utl=100,
|
||||
unit="%",
|
||||
vision_output="dxf.points_in_pct",
|
||||
))
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""#83: ricetta DXF compare da camera e calibrazione letta dall'acquisitore.
|
||||
|
||||
Il worker (W2) e l'acquisitore (C1) non girano nei test: si fingono, come
|
||||
fanno gli altri test di visione. La calibrazione vive nell'acquisitore esterno,
|
||||
TMF la legge via GET {camera_url}/camera/calibration.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.station import Station
|
||||
from src.backend.models.orm.task import RecipeSubtask
|
||||
from src.backend.models.orm.vision import VisionResult
|
||||
from src.backend.services import vision_service
|
||||
from src.backend.tests.conftest import auth_headers
|
||||
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
|
||||
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
|
||||
_camera_task, _execute, _station_with_run, worker_calls,
|
||||
)
|
||||
|
||||
CAMERA_URL = "http://10.0.0.9:8200"
|
||||
CAL = {
|
||||
"source": "camera:A1", "px_per_mm": 12.5, "mm_per_px": 0.08, "rms_px": 0.2,
|
||||
"n_corners": 40, "cols": 8, "rows": 5, "square_mm": 5.0, "image_width": 640,
|
||||
"image_height": 480, "scale_spread_pct": 0.1, "calibrated_at": "2026-10-23T10:00:00",
|
||||
}
|
||||
GRAPH = {
|
||||
"schema_version": 2, "name": "dxf-compare", "tools": [],
|
||||
"meta": {"dxf": {"disegno": {"path": "disegno.dxf"}}, "calibration": {"path": "calibrazione.json"}},
|
||||
}
|
||||
OUTPUTS = [{"key": "dxf.points_in_pct", "quantity": "ratio", "unit": "%"}]
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
@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 w2(monkeypatch):
|
||||
"""Finto worker W2: ricorda le chiamate e risponde col corpo del contratto."""
|
||||
calls: list[dict] = []
|
||||
|
||||
async def fake(image_bytes, dxf_bytes, **kwargs):
|
||||
calls.append({"dxf": dxf_bytes, **kwargs})
|
||||
return {
|
||||
"graph": GRAPH,
|
||||
"files": {
|
||||
"disegno.dxf": _b64(dxf_bytes),
|
||||
"calibrazione.json": _b64(json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()),
|
||||
},
|
||||
"outputs": {"dxf.points_in_pct": 99.0},
|
||||
"overlay": [], "outcome": "pass",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
|
||||
return calls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def validate_ok(monkeypatch):
|
||||
async def fake(graph, recipe_dir):
|
||||
return {"ok": True, "errors": [], "outputs": OUTPUTS}
|
||||
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acquisitore(monkeypatch):
|
||||
"""Finto C1: `state['cal']` = dict (200), None (404), 'down' (irraggiungibile)."""
|
||||
state: dict = {"cal": CAL, "urls": []}
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, code, body=None):
|
||||
self.status_code, self._body, self.text = code, body, ""
|
||||
|
||||
def json(self):
|
||||
return self._body
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
async def get(self, url, params=None):
|
||||
state["urls"].append((url, params))
|
||||
if state["cal"] == "down":
|
||||
raise httpx.ConnectError("giu'")
|
||||
if state["cal"] is None:
|
||||
return _Resp(404)
|
||||
return _Resp(200, state["cal"])
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _Client)
|
||||
return state
|
||||
|
||||
|
||||
async def _station(db_session, user, *, camera=True, worker_url=None, code="ST-C"):
|
||||
station = Station(
|
||||
code=code, name="Stazione C", active=True, created_by=user.id,
|
||||
vision_worker_url=worker_url,
|
||||
camera_url=CAMERA_URL if camera else None,
|
||||
camera_source="camera:A1" if camera else None,
|
||||
)
|
||||
db_session.add(station)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(station)
|
||||
return station
|
||||
|
||||
|
||||
def _post(client, user, task, *, save=False, **data):
|
||||
return client.post(
|
||||
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
|
||||
headers=auth_headers(user),
|
||||
files={
|
||||
"image": ("p.png", an_image(), "image/png"),
|
||||
"dxf": ("d.dxf", b"0\nEOF", "application/octet-stream"),
|
||||
},
|
||||
data={"save": str(save).lower(), **{k: str(v) for k, v in data.items() if v is not None}},
|
||||
)
|
||||
|
||||
|
||||
# --- lettura della calibrazione dall'acquisitore (C1) -------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_calibration_ok(db_session, maker_user, acquisitore):
|
||||
station = await _station(db_session, maker_user)
|
||||
cal, reason = await vision_service.fetch_station_calibration(station)
|
||||
assert (cal, reason) == (CAL, "ok")
|
||||
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_calibration_reasons(db_session, maker_user, acquisitore):
|
||||
station = await _station(db_session, maker_user)
|
||||
acquisitore["cal"] = None
|
||||
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
|
||||
acquisitore["cal"] = "down"
|
||||
assert await vision_service.fetch_station_calibration(station) == (None, "unreachable")
|
||||
assert await vision_service.get_station_calibration(station) is None
|
||||
acquisitore["cal"] = {**CAL, "px_per_mm": 0} # valore inutilizzabile = non calibrata
|
||||
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
|
||||
no_camera = await _station(db_session, maker_user, camera=False, code="ST-N")
|
||||
assert await vision_service.fetch_station_calibration(no_camera) == (None, "no_camera")
|
||||
assert await vision_service.fetch_station_calibration(None) == (None, "no_camera")
|
||||
|
||||
|
||||
# --- B3: POST /api/vision/tasks/{id}/recipe/dxf-compare -----------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_preview_writes_nothing(
|
||||
client, db_session, maker_user, w2, acquisitore,
|
||||
):
|
||||
task, subtask = await make_task(db_session, maker_user)
|
||||
station = await _station(db_session, maker_user, worker_url="http://10.0.0.7:8100")
|
||||
|
||||
resp = await _post(client, maker_user, task, station_id=station.id, band_mm=0.2,
|
||||
min_points_pct=90, polarity="light")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["saved"] is False and body["recipe_dir"] is None and body["outputs"] is None
|
||||
assert "files" not in body["result"] and body["result"]["graph"] == GRAPH
|
||||
# px_per_mm dall'acquisitore, parametri e worker della stazione
|
||||
assert w2[0]["px_per_mm"] == 12.5 and w2[0]["band_mm"] == 0.2
|
||||
assert w2[0]["min_points_pct"] == 90 and w2[0]["polarity"] == "light"
|
||||
assert w2[0]["worker_url"] == "http://10.0.0.7:8100"
|
||||
await db_session.refresh(task)
|
||||
assert task.vision_json is None and task.vision_recipe_dir is None
|
||||
quotes = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||
assert [q.id for q in quotes] == [subtask.id] # nessuna quota nuova
|
||||
assert not (settings.upload_path / "vision" / "recipes").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_save_stores_recipe_and_quote_once(
|
||||
client, db_session, maker_user, w2, validate_ok,
|
||||
):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
|
||||
resp = await _post(client, maker_user, task, save=True, px_per_mm=10, min_points_pct=95)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["saved"] is True and body["outputs"] == OUTPUTS
|
||||
assert "files" not in body["result"]
|
||||
folder = settings.upload_path / body["recipe_dir"]
|
||||
assert (folder / "disegno.dxf").read_bytes() == b"0\nEOF"
|
||||
assert json.loads((folder / "calibrazione.json").read_text()) == {"px_per_mm": 10.0}
|
||||
await db_session.refresh(task)
|
||||
assert task.vision_json == GRAPH and task.vision_recipe_dir == body["recipe_dir"]
|
||||
|
||||
quotes = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
|
||||
.where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||
assert len(quotes) == 1
|
||||
q = quotes[0]
|
||||
assert q.description == "Punti in tolleranza"
|
||||
assert (float(q.nominal), float(q.ltl), float(q.utl)) == (100.0, 95.0, 100.0)
|
||||
|
||||
# secondo salvataggio: la quota non si duplica
|
||||
task_id = task.id # dopo expire_all l'attributo scaduto non si rilegge in async
|
||||
again = await _post(client, maker_user, task, save=True, px_per_mm=10)
|
||||
assert again.status_code == 200
|
||||
db_session.expire_all()
|
||||
count = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.vision_output.is_not(None))
|
||||
.where(RecipeSubtask.task_id == task_id))).scalars().all()
|
||||
assert len(count) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_keeps_an_existing_vision_quote(
|
||||
client, db_session, maker_user, w2, validate_ok,
|
||||
):
|
||||
task, subtask = await make_task(db_session, maker_user)
|
||||
subtask.vision_output = "t1.diameter"
|
||||
await db_session.commit()
|
||||
|
||||
resp = await _post(client, maker_user, task, save=True, px_per_mm=10)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
quotes = (await db_session.execute(
|
||||
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
|
||||
assert len(quotes) == 1 # niente quota in piu'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_uncalibrated_station_is_422(
|
||||
client, db_session, maker_user, w2, acquisitore,
|
||||
):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
station = await _station(db_session, maker_user)
|
||||
acquisitore["cal"] = None
|
||||
resp = await _post(client, maker_user, task, station_id=station.id)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"] == ["stazione non calibrata"]
|
||||
assert w2 == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_unreachable_acquirer_is_422_with_its_own_message(
|
||||
client, db_session, maker_user, w2, acquisitore,
|
||||
):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
station = await _station(db_session, maker_user)
|
||||
acquisitore["cal"] = "down"
|
||||
resp = await _post(client, maker_user, task, station_id=station.id)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"] == ["acquisitore della stazione non raggiungibile"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_without_station_nor_scale_is_422(client, db_session, maker_user, w2):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await _post(client, maker_user, task)
|
||||
assert resp.status_code == 422 and resp.json()["detail"] == ["stazione non calibrata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_needs_maker(client, db_session, maker_user, measurement_tec_user, w2):
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await _post(client, measurement_tec_user, task, px_per_mm=10)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dxf_compare_worker_422_is_forwarded(
|
||||
client, db_session, maker_user, monkeypatch,
|
||||
):
|
||||
from fastapi import HTTPException
|
||||
|
||||
async def refuse(*a, **k):
|
||||
raise HTTPException(status_code=422, detail="pezzo non trovato")
|
||||
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", refuse)
|
||||
task, _ = await make_task(db_session, maker_user)
|
||||
resp = await _post(client, maker_user, task, px_per_mm=10)
|
||||
assert resp.status_code == 422 and resp.json()["detail"] == "pezzo non trovato"
|
||||
|
||||
|
||||
# --- execute: calibrazione della stazione -------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_passes_station_calibration_and_snapshots_it(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||
await db_session.commit()
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert worker_calls[0]["calibration"] == {"px_per_mm": 12.5}
|
||||
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
assert result.calibration_snapshot == CAL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_without_calibration_is_unchanged(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
|
||||
await db_session.commit()
|
||||
acquisitore["cal"] = "down" # acquisitore giu': si misura come prima, senza calibrazione
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert "calibration" not in worker_calls[0]
|
||||
result = (await db_session.execute(select(VisionResult))).scalars().one()
|
||||
assert result.calibration_snapshot is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_station_without_camera_never_asks(
|
||||
client, db_session, measurement_tec_user, worker_calls, acquisitore,
|
||||
):
|
||||
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
|
||||
_, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
|
||||
|
||||
await _execute(client, measurement_tec_user, task, run)
|
||||
|
||||
assert acquisitore["urls"] == [] and "calibration" not in worker_calls[0]
|
||||
@@ -4,9 +4,7 @@ import math
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask_babel import gettext as _
|
||||
|
||||
from blueprints.auth import login_required, role_required
|
||||
@@ -485,173 +483,6 @@ def api_upload_vision_recipe(task_id: int):
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RICETTA DXF DA CAMERA (#83): stazioni, camera, calibrazione, creazione ricetta
|
||||
# ============================================================================
|
||||
|
||||
def maker_or_admin_required(f):
|
||||
"""Maker dell'editor oppure admin della pagina stazioni (#83: stesse rotte camera)."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
user = session.get("user", {})
|
||||
if not (user.get("is_admin") or "Maker" in user.get("roles", [])):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def _stations_for_maker() -> list[dict]:
|
||||
"""Stazioni visibili al chiamante.
|
||||
|
||||
GET /api/stations e' solo admin: un Maker non admin ripiega sulla stazione
|
||||
corrente (by-code, aperta a tutti), cosi' l'editor funziona comunque.
|
||||
"""
|
||||
resp = api_client.get("/api/stations")
|
||||
if isinstance(resp, list):
|
||||
return resp
|
||||
if Config.STATION_CODE:
|
||||
one = api_client.get_station_by_code(Config.STATION_CODE)
|
||||
if isinstance(one, dict) and not one.get("error"):
|
||||
return [one]
|
||||
return []
|
||||
|
||||
|
||||
def _camera_of_station(station_id: int) -> tuple[str, str] | None:
|
||||
"""(camera_url, camera_source) della stazione con questo id, None se senza camera."""
|
||||
for st in _stations_for_maker():
|
||||
if st.get("id") == station_id:
|
||||
if st.get("camera_url") and st.get("camera_source"):
|
||||
return str(st["camera_url"]), str(st["camera_source"])
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@maker_bp.route("/api/stations", methods=["GET"])
|
||||
@login_required
|
||||
@maker_or_admin_required
|
||||
def api_list_stations_for_maker():
|
||||
"""Elenco stazioni con camera per il pannello «Crea ricetta da DXF»."""
|
||||
out = []
|
||||
for st in _stations_for_maker():
|
||||
if st.get("camera_url") and st.get("camera_source"):
|
||||
out.append({
|
||||
"id": st.get("id"), "code": st.get("code"), "name": st.get("name"),
|
||||
"camera_url": st.get("camera_url"),
|
||||
})
|
||||
return jsonify(out), 200
|
||||
|
||||
|
||||
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
|
||||
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@maker_or_admin_required
|
||||
def api_station_camera_grab(station_id: int):
|
||||
"""Proxy: un frame dalla camera della stazione, risposta PNG."""
|
||||
camera = _camera_of_station(station_id)
|
||||
if camera is None:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Camera non configurata per questa stazione"),
|
||||
}), 409
|
||||
camera_url, camera_source = camera
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{camera_url.rstrip('/')}/camera/grab",
|
||||
json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000},
|
||||
timeout=15,
|
||||
)
|
||||
except http_requests.RequestException as exc:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
|
||||
}), 502
|
||||
if resp.status_code != 200:
|
||||
detail = ""
|
||||
try:
|
||||
body = resp.json()
|
||||
detail = body.get("detail", "") if isinstance(body, dict) else ""
|
||||
except ValueError:
|
||||
detail = (resp.text or "")[:200]
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Acquisizione non riuscita (%(status)s): %(detail)s",
|
||||
status=resp.status_code, detail=detail),
|
||||
}), 502
|
||||
return Response(resp.content, content_type="image/png")
|
||||
|
||||
|
||||
@maker_bp.route("/api/stations/<int:station_id>/camera/calibration", methods=["GET"])
|
||||
@login_required
|
||||
@maker_or_admin_required
|
||||
def api_station_camera_calibration(station_id: int):
|
||||
"""Proxy: calibrazione della camera letta dall'acquisitore (C1).
|
||||
|
||||
Risponde sempre 200 con `state`: calibrated | uncalibrated | unreachable,
|
||||
cosi' la UI mostra lo stato senza trattare «non calibrata» come errore.
|
||||
409 solo se la stazione non ha camera.
|
||||
"""
|
||||
camera = _camera_of_station(station_id)
|
||||
if camera is None:
|
||||
return jsonify({
|
||||
"error": True,
|
||||
"detail": _("Camera non configurata per questa stazione"),
|
||||
}), 409
|
||||
camera_url, camera_source = camera
|
||||
base = camera_url.rstrip("/")
|
||||
link = f"{base}/calibrazione" # pagina di calibrazione dell'acquisitore (C4)
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{base}/camera/calibration",
|
||||
params={"source": camera_source},
|
||||
timeout=10,
|
||||
)
|
||||
except http_requests.RequestException as exc:
|
||||
return jsonify({"state": "unreachable", "detail": str(exc), "calibration_url": link}), 200
|
||||
if resp.status_code == 404:
|
||||
return jsonify({"state": "uncalibrated", "calibration_url": link}), 200
|
||||
if resp.status_code != 200:
|
||||
return jsonify({
|
||||
"state": "unreachable", "calibration_url": link,
|
||||
"detail": _("Risposta %(status)s dall'acquisitore", status=resp.status_code),
|
||||
}), 200
|
||||
try:
|
||||
calibration = resp.json()
|
||||
except ValueError:
|
||||
return jsonify({"state": "unreachable", "calibration_url": link,
|
||||
"detail": _("Risposta non valida dall'acquisitore")}), 200
|
||||
return jsonify({"state": "calibrated", "calibration": calibration, "calibration_url": link}), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_dxf_compare_recipe(task_id: int):
|
||||
"""Proxy: crea (o prova, save=false) la ricetta DXF compare da camera (B3)."""
|
||||
image = request.files.get("image")
|
||||
dxf = request.files.get("dxf")
|
||||
if image is None or not image.filename or dxf is None or not dxf.filename:
|
||||
return jsonify({"error": True, "detail": _("Servono l'immagine e il file DXF")}), 400
|
||||
|
||||
# Solo i campi del contratto B3; i vuoti non si inoltrano (il backend ha i default)
|
||||
data = {}
|
||||
for key in ("station_id", "px_per_mm", "band_mm", "min_points_pct", "polarity", "save"):
|
||||
value = request.form.get(key)
|
||||
if value not in (None, ""):
|
||||
data[key] = value
|
||||
|
||||
resp = api_client.post(
|
||||
f"/api/vision/tasks/{task_id}/recipe/dxf-compare",
|
||||
data=data,
|
||||
files={
|
||||
"image": (image.filename, image.read(), image.mimetype or "application/octet-stream"),
|
||||
"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream"),
|
||||
},
|
||||
)
|
||||
if resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// «Crea ricetta da DXF» dell'editor task (#83): da un'immagine della camera e un DXF
|
||||
// genera la ricetta dxf_compare (POST /maker/api/vision/tasks/<id>/recipe/dxf-compare).
|
||||
// «Prova» (save=false) mostra overlay e quattro valori, «Salva ricetta» (save=true)
|
||||
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
|
||||
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
|
||||
function dxfRecipeWizard(taskId, csrf, msgs) {
|
||||
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
|
||||
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
|
||||
const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
|
||||
|
||||
return {
|
||||
taskId: taskId,
|
||||
msgs: msgs || {},
|
||||
open: false,
|
||||
stations: [],
|
||||
stationId: '',
|
||||
calState: '', // '' | calibrated | uncalibrated | unreachable
|
||||
calLabel: '',
|
||||
calUrl: '',
|
||||
imageFile: null, // immagine caricata o acquisita
|
||||
imageName: '',
|
||||
imageSrc: '',
|
||||
dxfFile: null,
|
||||
bandMm: 0.1,
|
||||
minPointsPct: 95,
|
||||
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
|
||||
busy: false,
|
||||
grabbing: false,
|
||||
error: '',
|
||||
notice: '',
|
||||
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
|
||||
|
||||
async toggle() {
|
||||
this.open = !this.open;
|
||||
if (this.open && !this.stations.length) await this.loadStations();
|
||||
},
|
||||
|
||||
async loadStations() {
|
||||
try {
|
||||
const resp = await fetch('/maker/api/stations');
|
||||
if (resp.ok) this.stations = await resp.json();
|
||||
} catch (e) { console.error('dxfRecipeWizard stations', e); }
|
||||
// Una sola stazione con camera: preselezionata
|
||||
if (this.stations.length === 1) {
|
||||
this.stationId = String(this.stations[0].id);
|
||||
await this.onStation();
|
||||
}
|
||||
},
|
||||
|
||||
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
|
||||
async onStation() {
|
||||
this.calState = ''; this.calLabel = ''; this.calUrl = '';
|
||||
this.result = null;
|
||||
if (!this.stationId) return;
|
||||
const r = await fetchStationCalibration(this.stationId);
|
||||
this.calState = r.state || 'unreachable';
|
||||
this.calUrl = r.calibration_url || '';
|
||||
if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {});
|
||||
},
|
||||
|
||||
get calibrated() { return this.calState === 'calibrated'; },
|
||||
get canRun() {
|
||||
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy;
|
||||
},
|
||||
|
||||
_setImage(file) {
|
||||
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
|
||||
this.imageFile = file;
|
||||
this.imageName = file.name || '';
|
||||
this.imageSrc = URL.createObjectURL(file);
|
||||
this.result = null;
|
||||
},
|
||||
|
||||
onImage(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
if (f) this._setImage(f);
|
||||
},
|
||||
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
|
||||
|
||||
// Frame dalla camera della stazione scelta
|
||||
async grab() {
|
||||
if (!this.stationId || this.grabbing) return;
|
||||
this.grabbing = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const resp = await fetch(`/maker/api/stations/${this.stationId}/camera/grab`, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': this._csrf() },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
this.error = body.detail || this.msgs.requestFailed || 'Error';
|
||||
} else {
|
||||
const blob = await resp.blob();
|
||||
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }));
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = this.msgs.requestFailed || String(e);
|
||||
}
|
||||
this.grabbing = false;
|
||||
},
|
||||
|
||||
_csrf() { return typeof csrf === 'function' ? csrf() : csrf; },
|
||||
|
||||
// Il 422 porta `detail` (stringa o lista) oppure `errors`
|
||||
_errorText(body) {
|
||||
if (Array.isArray(body.errors) && body.errors.length) return body.errors.map(String).join('; ');
|
||||
const d = body.detail;
|
||||
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))).join('; ');
|
||||
if (d && typeof d === 'object') return JSON.stringify(d);
|
||||
return d || this.msgs.requestFailed || 'Error';
|
||||
},
|
||||
|
||||
async run(save) {
|
||||
if (!this.canRun) return;
|
||||
this.busy = true;
|
||||
this.error = '';
|
||||
this.notice = '';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
|
||||
fd.append('dxf', this.dxfFile, this.dxfFile.name);
|
||||
fd.append('station_id', this.stationId);
|
||||
fd.append('band_mm', String(this.bandMm));
|
||||
fd.append('min_points_pct', String(this.minPointsPct));
|
||||
fd.append('polarity', this.dark ? 'dark' : 'light');
|
||||
fd.append('save', save ? 'true' : 'false');
|
||||
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare`, {
|
||||
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
|
||||
});
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || body.error) {
|
||||
this.error = this._errorText(body);
|
||||
} else {
|
||||
this.result = body;
|
||||
this.$nextTick(() => this.drawOverlay());
|
||||
if (save && body.saved) {
|
||||
this.notice = this.msgs.saved || 'Saved';
|
||||
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
|
||||
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = this.msgs.requestFailed || String(e);
|
||||
}
|
||||
this.busy = false;
|
||||
},
|
||||
|
||||
// Le quattro uscite del confronto, nell'ordine fisso
|
||||
get values() {
|
||||
const out = (this.result && this.result.result && this.result.result.outputs) || {};
|
||||
return VALUE_KEYS.map(k => ({ key: k, value: out[k] }));
|
||||
},
|
||||
|
||||
fmt(v) {
|
||||
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
|
||||
{ maximumFractionDigits: 3 }) : '-';
|
||||
},
|
||||
|
||||
// Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine
|
||||
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';
|
||||
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
|
||||
for (const item of shapes) {
|
||||
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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Stato di calibrazione della camera di una stazione (#83).
|
||||
// La calibrazione vive nell'acquisitore (camera_service): qui si legge solo lo stato
|
||||
// tramite il proxy Flask e si offre il link alla sua pagina «Calibra».
|
||||
// Uso: x-data="stationCalibrationCell(station, msgs)" (msgs: stringhe tradotte). Dipende da Alpine.js.
|
||||
|
||||
// Formatta px/mm e data nella lingua della pagina (es. «20,03 px/mm · 23/10/2026»)
|
||||
function formatCalibration(cal) {
|
||||
const lang = document.documentElement.lang || 'it';
|
||||
let text = new Intl.NumberFormat(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
.format(Number(cal.px_per_mm)) + ' px/mm';
|
||||
if (cal.calibrated_at) {
|
||||
const d = new Date(cal.calibrated_at);
|
||||
if (!isNaN(d)) text += ' · ' + d.toLocaleDateString(lang);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// Legge lo stato dal proxy: ritorna {state, calibration, calibration_url, detail}
|
||||
async function fetchStationCalibration(stationId) {
|
||||
try {
|
||||
const resp = await fetch(`/maker/api/stations/${stationId}/camera/calibration`);
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) return { state: 'unreachable', detail: body.detail || '' };
|
||||
return body;
|
||||
} catch (e) {
|
||||
return { state: 'unreachable', detail: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
function stationCalibrationCell(station, msgs) {
|
||||
return {
|
||||
hasCamera: !!(station.camera_url && station.camera_source),
|
||||
cameraUrl: station.camera_url || '',
|
||||
state: '', // '' = in lettura | calibrated | uncalibrated | unreachable
|
||||
label: '',
|
||||
calibrationUrl: '',
|
||||
msgs: msgs || {},
|
||||
|
||||
async init() {
|
||||
if (!this.hasCamera) return;
|
||||
const r = await fetchStationCalibration(station.id);
|
||||
this.state = r.state || 'unreachable';
|
||||
// Link «Calibra»: dal proxy, altrimenti costruito dall'indirizzo della camera
|
||||
this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/calibrazione');
|
||||
if (this.state === 'calibrated') this.label = formatCalibration(r.calibration || {});
|
||||
else if (this.state === 'uncalibrated') this.label = this.msgs.uncalibrated || 'not calibrated';
|
||||
else this.label = this.msgs.unreachable || 'camera service unreachable';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -50,7 +50,6 @@
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Calibrazione') }}</th>
|
||||
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
|
||||
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
|
||||
</tr>
|
||||
@@ -82,25 +81,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
{# Calibrazione (#83): stato letto dall'acquisitore della camera, link alla sua pagina «Calibra» #}
|
||||
<td class="px-4 py-3 hidden md:table-cell" @click.stop
|
||||
x-data="stationCalibrationCell(station, {{ {'uncalibrated': _('non calibrata'), 'unreachable': _('acquisitore non raggiungibile')}|tojson_attr }})">
|
||||
<template x-if="!hasCamera">
|
||||
<span class="text-xs italic text-[var(--text-secondary)]">{{ _('Nessuna camera') }}</span>
|
||||
</template>
|
||||
<template x-if="hasCamera">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<span :class="state === 'calibrated' ? 'text-emerald-700 dark:text-emerald-300'
|
||||
: (state === 'uncalibrated' || state === 'unreachable') ? 'text-amber-700 dark:text-amber-300'
|
||||
: 'text-[var(--text-secondary)]'">
|
||||
<span x-show="state === 'calibrated'">{{ _('Calibrazione') }}: </span><span x-text="state ? label : '...'"></span>
|
||||
</span>
|
||||
<a :href="calibrationUrl" target="_blank" rel="noopener"
|
||||
x-show="calibrationUrl"
|
||||
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
|
||||
:class="station.active
|
||||
@@ -479,7 +459,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
|
||||
<script>
|
||||
function stationManagement(initialStations, initialRecipes) {
|
||||
return {
|
||||
|
||||
@@ -550,8 +550,7 @@
|
||||
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
|
||||
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
||||
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
|
||||
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})"
|
||||
@dxf-recipe-saved="init()">{# #83: dopo «Salva ricetta» da DXF si rilegge la ricetta #}
|
||||
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
|
||||
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
||||
{{ _('Ricetta visione') }}
|
||||
</p>
|
||||
@@ -594,93 +593,6 @@
|
||||
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
|
||||
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
|
||||
</ul>
|
||||
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
|
||||
<template x-if="task.task_type === 'dxf_compare'">
|
||||
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
|
||||
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata')}|tojson_attr }})">
|
||||
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
|
||||
{{ _('Crea ricetta da DXF') }}
|
||||
</button>
|
||||
<div x-show="open" x-cloak class="mt-3 space-y-3">
|
||||
<!-- Stazione -->
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
|
||||
<select class="tmf-input text-xs" x-model="stationId" @change="onStation()">
|
||||
<option value="">{{ _('Scegli la stazione') }}</option>
|
||||
<template x-for="st in stations" :key="st.id">
|
||||
<option :value="String(st.id)" x-text="st.code + ' - ' + st.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p x-show="!stations.length" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Nessuna stazione con camera configurata') }}</p>
|
||||
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
|
||||
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
|
||||
</p>
|
||||
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600">
|
||||
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
|
||||
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
|
||||
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
|
||||
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Immagine: camera o file -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button type="button" class="btn btn-secondary text-xs" @click="grab()"
|
||||
:disabled="!stationId || grabbing">{{ _('Acquisisci dalla camera') }}</button>
|
||||
<label class="btn btn-secondary text-xs cursor-pointer">
|
||||
{{ _('Carica immagine') }}
|
||||
<input type="file" accept="image/*" class="hidden" @change="onImage($event)">
|
||||
</label>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
|
||||
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pochi parametri -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Banda ± mm') }}</label>
|
||||
<input type="number" step="0.01" min="0.001" class="tmf-input text-xs" x-model.number="bandMm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Soglia punti in banda %%') }}</label>
|
||||
<input type="number" step="1" min="1" max="100" class="tmf-input text-xs" x-model.number="minPointsPct">
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" x-model="dark">
|
||||
{{ _('Pezzo scuro su fondo chiaro (controluce)') }}
|
||||
</label>
|
||||
<!-- Azioni -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-secondary text-xs" @click="run(false)"
|
||||
:disabled="!canRun">{{ _('Prova') }}</button>
|
||||
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
|
||||
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
|
||||
</div>
|
||||
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
||||
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
|
||||
<!-- Anteprima con overlay e quattro valori -->
|
||||
<div x-show="imageSrc" class="relative">
|
||||
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
|
||||
@load="drawOverlay()">
|
||||
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
||||
</div>
|
||||
<table x-show="result" class="tmf-table text-xs">
|
||||
<tbody>
|
||||
<template x-for="v in values" :key="v.key">
|
||||
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1333,8 +1245,6 @@
|
||||
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
||||
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
||||
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
"""gitea:Adriano/TieMeasureFlow#83 - ricetta DXF compare da camera: rotte Flask nuove.
|
||||
|
||||
api_client e requests sono finti: backend (B3) e acquisitore (C1) rispondono
|
||||
come da contratto del piano.
|
||||
"""
|
||||
import io
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from tests.conftest import api_get_router
|
||||
|
||||
STATION = {
|
||||
"id": 4, "code": "ST-4", "name": "Banco",
|
||||
"camera_url": "http://acq:8765/", "camera_source": "camera:folder",
|
||||
}
|
||||
NO_CAMERA = {"id": 5, "code": "ST-5", "name": "Senza", "camera_url": None, "camera_source": None}
|
||||
PNG = b"\x89PNG\r\n\x1a\n fake"
|
||||
CAL = {
|
||||
"source": "camera:folder", "px_per_mm": 20.03, "mm_per_px": 0.0499, "rms_px": 0.2,
|
||||
"n_corners": 54, "cols": 9, "rows": 6, "square_mm": 5.0,
|
||||
"image_width": 2448, "image_height": 2048, "scale_spread_pct": 0.3,
|
||||
"calibrated_at": "2026-10-23T10:00:00",
|
||||
}
|
||||
|
||||
|
||||
def _fake(status=200, content=PNG, json_body=None):
|
||||
resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1"))
|
||||
if json_body is None:
|
||||
resp.json.side_effect = ValueError("no json")
|
||||
else:
|
||||
resp.json.return_value = json_body
|
||||
return resp
|
||||
|
||||
|
||||
def _stations(mock_api_client, stations=(STATION, NO_CAMERA)):
|
||||
mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)})
|
||||
|
||||
|
||||
class TestStationList:
|
||||
def test_only_stations_with_camera(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
resp = logged_in_client.get("/maker/api/stations")
|
||||
assert resp.status_code == 200
|
||||
assert [s["id"] for s in resp.get_json()] == [4]
|
||||
|
||||
def test_non_admin_list_falls_back_to_current_station(
|
||||
self, logged_in_client, mock_api_client, monkeypatch):
|
||||
# /api/stations e' solo admin: il Maker vede la stazione corrente (by-code)
|
||||
monkeypatch.setattr("blueprints.maker.Config.STATION_CODE", "ST-4")
|
||||
mock_api_client.get.side_effect = api_get_router(
|
||||
{"/api/stations": {"error": True, "status_code": 403, "detail": "no"}})
|
||||
mock_api_client.get_station_by_code.return_value = STATION
|
||||
resp = logged_in_client.get("/maker/api/stations")
|
||||
assert [s["id"] for s in resp.get_json()] == [4]
|
||||
mock_api_client.get_station_by_code.assert_called_once_with("ST-4")
|
||||
|
||||
def test_requires_maker_or_admin(self, logged_in_client, mock_api_client):
|
||||
with logged_in_client.session_transaction() as sess:
|
||||
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"], "is_admin": False}
|
||||
assert logged_in_client.get("/maker/api/stations").status_code == 403
|
||||
|
||||
|
||||
class TestStationCameraGrab:
|
||||
def test_png_pass_through(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
with patch("blueprints.maker.http_requests.post", return_value=_fake()) as post:
|
||||
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
|
||||
assert resp.status_code == 200
|
||||
assert resp.mimetype == "image/png"
|
||||
assert resp.data == PNG
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto
|
||||
assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000}
|
||||
|
||||
def test_get_also_works(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
with patch("blueprints.maker.http_requests.post", return_value=_fake()):
|
||||
assert logged_in_client.get("/maker/api/stations/4/camera/grab").status_code == 200
|
||||
|
||||
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
with patch("blueprints.maker.http_requests.post") as post:
|
||||
resp = logged_in_client.post("/maker/api/stations/5/camera/grab")
|
||||
assert resp.status_code == 409
|
||||
post.assert_not_called()
|
||||
|
||||
def test_unknown_station_is_409(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
assert logged_in_client.post("/maker/api/stations/99/camera/grab").status_code == 409
|
||||
|
||||
def test_service_down_is_502(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
with patch("blueprints.maker.http_requests.post",
|
||||
side_effect=requests.ConnectionError("refused")):
|
||||
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
|
||||
assert resp.status_code == 502
|
||||
assert resp.get_json()["error"]
|
||||
|
||||
def test_service_503_is_502_with_detail(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
fake = _fake(503, b"{}", {"detail": "camera occupata"})
|
||||
with patch("blueprints.maker.http_requests.post", return_value=fake):
|
||||
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
|
||||
assert resp.status_code == 502
|
||||
assert "camera occupata" in resp.get_json()["detail"]
|
||||
|
||||
|
||||
class TestStationCameraCalibration:
|
||||
def _get(self, client, fake=None, exc=None):
|
||||
with patch("blueprints.maker.http_requests.get", return_value=fake,
|
||||
side_effect=exc) as get:
|
||||
resp = client.get("/maker/api/stations/4/camera/calibration")
|
||||
return resp, get
|
||||
|
||||
def test_calibrated(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
resp, get = self._get(logged_in_client, _fake(200, b"{}", CAL))
|
||||
body = resp.get_json()
|
||||
assert resp.status_code == 200
|
||||
assert body["state"] == "calibrated"
|
||||
assert body["calibration"]["px_per_mm"] == 20.03
|
||||
assert body["calibration_url"] == "http://acq:8765/calibrazione"
|
||||
args, kwargs = get.call_args
|
||||
assert args[0] == "http://acq:8765/camera/calibration"
|
||||
assert kwargs["params"] == {"source": "camera:folder"}
|
||||
|
||||
def test_404_is_uncalibrated(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
resp, _ = self._get(logged_in_client, _fake(404, b"{}", {"detail": "no"}))
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["state"] == "uncalibrated"
|
||||
assert resp.get_json()["calibration_url"] == "http://acq:8765/calibrazione"
|
||||
|
||||
def test_unreachable(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
resp, _ = self._get(logged_in_client, exc=requests.ConnectionError("down"))
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["state"] == "unreachable"
|
||||
|
||||
def test_server_error_is_unreachable(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
resp, _ = self._get(logged_in_client, _fake(500, b"boom"))
|
||||
assert resp.get_json()["state"] == "unreachable"
|
||||
|
||||
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
with patch("blueprints.maker.http_requests.get") as get:
|
||||
resp = logged_in_client.get("/maker/api/stations/5/camera/calibration")
|
||||
assert resp.status_code == 409
|
||||
get.assert_not_called()
|
||||
|
||||
|
||||
class TestDxfCompareProxy:
|
||||
URL = "/maker/api/vision/tasks/7/recipe/dxf-compare"
|
||||
OK = {
|
||||
"result": {"outputs": {"dxf.points_in_pct": 99.1}, "overlay": [], "outcome": "pass"},
|
||||
"saved": False, "recipe_dir": None, "outputs": None,
|
||||
}
|
||||
|
||||
def _post(self, client, **form):
|
||||
data = {"image": (io.BytesIO(PNG), "p.png"), "dxf": (io.BytesIO(b"0\nEOF"), "d.dxf"), **form}
|
||||
return client.post(self.URL, data=data, content_type="multipart/form-data")
|
||||
|
||||
def test_forwards_files_and_fields(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = self.OK
|
||||
resp = self._post(logged_in_client, station_id="4", band_mm="0.1",
|
||||
min_points_pct="95", polarity="dark", save="false", px_per_mm="")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["result"]["outputs"]["dxf.points_in_pct"] == 99.1
|
||||
args, kwargs = mock_api_client.post.call_args
|
||||
assert args[0] == "/api/vision/tasks/7/recipe/dxf-compare"
|
||||
# campi del contratto B3; px_per_mm vuoto non si inoltra
|
||||
assert kwargs["data"] == {
|
||||
"station_id": "4", "band_mm": "0.1", "min_points_pct": "95",
|
||||
"polarity": "dark", "save": "false",
|
||||
}
|
||||
assert set(kwargs["files"]) == {"image", "dxf"}
|
||||
assert kwargs["files"]["image"][1] == PNG
|
||||
|
||||
def test_save_true_forwarded(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
|
||||
resp = self._post(logged_in_client, save="true")
|
||||
assert resp.get_json()["saved"] is True
|
||||
assert mock_api_client.post.call_args.kwargs["data"]["save"] == "true"
|
||||
|
||||
def test_missing_files_is_400(self, logged_in_client, mock_api_client):
|
||||
resp = logged_in_client.post(self.URL, data={"station_id": "4"},
|
||||
content_type="multipart/form-data")
|
||||
assert resp.status_code == 400
|
||||
mock_api_client.post.assert_not_called()
|
||||
|
||||
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
|
||||
mock_api_client.post.return_value = {
|
||||
"error": True, "status_code": 422, "detail": "stazione non calibrata"}
|
||||
resp = self._post(logged_in_client)
|
||||
assert resp.status_code == 422
|
||||
assert resp.get_json()["detail"] == "stazione non calibrata"
|
||||
|
||||
def test_requires_maker_role(self, logged_in_client, mock_api_client):
|
||||
with logged_in_client.session_transaction() as sess:
|
||||
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
|
||||
assert self._post(logged_in_client).status_code == 403
|
||||
mock_api_client.post.assert_not_called()
|
||||
|
||||
|
||||
class TestPages:
|
||||
def test_stations_page_has_calibration_cell(self, logged_in_client):
|
||||
with patch("blueprints.admin.api_client") as admin_api:
|
||||
admin_api.get.side_effect = api_get_router({"/api/stations": [STATION], "/api/recipes": []})
|
||||
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
|
||||
assert "js/station_calibration.js" in html
|
||||
assert "stationCalibrationCell(" in html
|
||||
# niente piu' pannello di calibrazione nella pagina: solo il link all'acquisitore
|
||||
assert "calibration-panel" not in html
|
||||
|
||||
def _editor(self, client, mock_api_client, task_type):
|
||||
mock_api_client.get.side_effect = api_get_router({
|
||||
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
|
||||
"/api/recipes/3/tasks": [{
|
||||
"id": 11, "title": "T", "task_type": task_type, "subtasks": [],
|
||||
"file_path": None, "order_index": 1,
|
||||
}],
|
||||
})
|
||||
return client.get("/maker/recipes/3/tasks").get_data(as_text=True)
|
||||
|
||||
def test_editor_has_dxf_wizard(self, logged_in_client, mock_api_client):
|
||||
html = self._editor(logged_in_client, mock_api_client, "dxf_compare")
|
||||
assert "js/dxf_recipe_wizard.js" in html
|
||||
assert "dxfRecipeWizard(" in html
|
||||
assert "@dxf-recipe-saved" in html
|
||||
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
|
||||
@@ -3359,96 +3359,3 @@ msgstr "Camera service unreachable: %(detail)s"
|
||||
#, python-format
|
||||
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||
msgstr "Capture failed (%(status)s): %(detail)s"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
#, python-format
|
||||
msgid "Risposta %(status)s dall'acquisitore"
|
||||
msgstr "Response %(status)s from the camera service"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Risposta non valida dall'acquisitore"
|
||||
msgstr "Invalid response from the camera service"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Servono l'immagine e il file DXF"
|
||||
msgstr "Both the image and the DXF file are required"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Calibrazione"
|
||||
msgstr "Calibration"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "non calibrata"
|
||||
msgstr "not calibrated"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "acquisitore non raggiungibile"
|
||||
msgstr "camera service unreachable"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Nessuna camera"
|
||||
msgstr "No camera"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Calibra"
|
||||
msgstr "Calibrate"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Ricetta salvata"
|
||||
msgstr "Recipe saved"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Crea ricetta da DXF"
|
||||
msgstr "Create recipe from DXF"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Scegli la stazione"
|
||||
msgstr "Choose the station"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Nessuna stazione con camera configurata"
|
||||
msgstr "No station with a camera configured"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
|
||||
msgstr "Station not calibrated: calibrate it before creating the recipe."
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
|
||||
msgstr "Camera service unreachable: cannot read the calibration."
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "oppure vedi la pagina Stazioni"
|
||||
msgstr "or see the Stations page"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Immagine del pezzo"
|
||||
msgstr "Part image"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Carica immagine"
|
||||
msgstr "Upload image"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Disegno (.dxf)"
|
||||
msgstr "Drawing (.dxf)"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Banda ± mm"
|
||||
msgstr "Band ± mm"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Soglia punti in banda %%"
|
||||
msgstr "Points-in-band threshold %%"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Pezzo scuro su fondo chiaro (controluce)"
|
||||
msgstr "Dark part on light background (backlight)"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Prova"
|
||||
msgstr "Try"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Salva ricetta"
|
||||
msgstr "Save recipe"
|
||||
|
||||
@@ -3347,96 +3347,3 @@ msgstr "Servizio camera non raggiungibile: %(detail)s"
|
||||
#, python-format
|
||||
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||
msgstr "Acquisizione non riuscita (%(status)s): %(detail)s"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
#, python-format
|
||||
msgid "Risposta %(status)s dall'acquisitore"
|
||||
msgstr "Risposta %(status)s dall'acquisitore"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Risposta non valida dall'acquisitore"
|
||||
msgstr "Risposta non valida dall'acquisitore"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Servono l'immagine e il file DXF"
|
||||
msgstr "Servono l'immagine e il file DXF"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Calibrazione"
|
||||
msgstr "Calibrazione"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "non calibrata"
|
||||
msgstr "non calibrata"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "acquisitore non raggiungibile"
|
||||
msgstr "acquisitore non raggiungibile"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Nessuna camera"
|
||||
msgstr "Nessuna camera"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Calibra"
|
||||
msgstr "Calibra"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Ricetta salvata"
|
||||
msgstr "Ricetta salvata"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Crea ricetta da DXF"
|
||||
msgstr "Crea ricetta da DXF"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Scegli la stazione"
|
||||
msgstr "Scegli la stazione"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Nessuna stazione con camera configurata"
|
||||
msgstr "Nessuna stazione con camera configurata"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
|
||||
msgstr "Stazione non calibrata: calibrala prima di creare la ricetta."
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
|
||||
msgstr "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "oppure vedi la pagina Stazioni"
|
||||
msgstr "oppure vedi la pagina Stazioni"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Immagine del pezzo"
|
||||
msgstr "Immagine del pezzo"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Carica immagine"
|
||||
msgstr "Carica immagine"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Disegno (.dxf)"
|
||||
msgstr "Disegno (.dxf)"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Banda ± mm"
|
||||
msgstr "Banda ± mm"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Soglia punti in banda %%"
|
||||
msgstr "Soglia punti in banda %%"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Pezzo scuro su fondo chiaro (controluce)"
|
||||
msgstr "Pezzo scuro su fondo chiaro (controluce)"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Prova"
|
||||
msgstr "Prova"
|
||||
|
||||
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
|
||||
msgid "Salva ricetta"
|
||||
msgstr "Salva ricetta"
|
||||
|
||||
Reference in New Issue
Block a user