Compare commits

..

7 Commits

Author SHA1 Message Date
Adriano 444dc0a6d1 backend: immagini BMP ammesse nelle rotte visione (Refs #99)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 15:46:58 +01:00
Adriano 4b1b23cfb6 Merge branch 'lavoro/daitarn-tmf-97c' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:04 +02:00
Adriano 4b57efc73c Merge branch 'lavoro/daitarn-tmf-97b' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:03 +02:00
Adriano 7c03f53000 Merge branch 'lavoro/daitarn-tmf-97a' into lavoro/daitarn-tmf-dxfcal 2026-10-24 19:33:03 +02:00
Adriano 661b782bad backend: test camera e modo di analisi nel task, /vision/check (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:26:26 +02:00
Adriano 25f8b4d609 maker: camera e modo di analisi nel task visione (Refs #97)
Rotte /maker/api/camera/servizi e /maker/api/camera/sorgenti (url ammesso solo
se e' un servizio delle stazioni), sezione «Acquisizione» nell'editor task
(servizio, sorgente, analisi impulsiva/in linea) salvata come acquisition_json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 18:26:00 +02:00
Adriano 8f9731839a backend: camera e modo di analisi nel task, /vision/check senza salvataggio (Refs #97)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 18:20:14 +02:00
15 changed files with 1015 additions and 25 deletions
+2
View File
@@ -250,6 +250,8 @@ async def create_task(
file_type=data.file_type,
annotations_json=data.annotations_json,
vision_json=data.vision_json,
# #97: camera e modo di analisi (dict gia' validato dal modello)
acquisition_json=data.acquisition_json.model_dump() if data.acquisition_json else None,
# #53 #55: ciclo (default dal tipo), chiusura, campi extra
in_loop=data.in_loop if data.in_loop is not None else default_in_loop(data.task_type),
at_close=data.at_close,
+30 -6
View File
@@ -23,7 +23,7 @@ from src.backend.api.routers.files import (
from src.backend.config import settings
from src.backend.database import get_db
from src.backend.models.api.vision import (
VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionCheckResponse, VisionExecuteResponse, VisionMeasurementResult, VisionRecipeResponse,
VisionRecipeUploadResponse, VisionReferenceImageResponse,
)
from src.backend.models.orm.vision import VisionResult
@@ -40,14 +40,22 @@ _IMAGE_EXTENSIONS = {
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp", # #99
"image/x-ms-bmp": "bmp", # #99
}
# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the
# vision routes accept it on top of files.py's list. files.py is untouched: its
# attachments are served by extension and BMP is not meant to be shown there.
_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"}
# Sniffed from the first bytes, independent of the declared Content-Type: a
# text file uploaded as "image/png" passes the header check above and must
# still be caught before it reaches the worker as a bare 500 (#15). BMP and
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
# because PIL - and so the worker - can decode both; this check only refuses
# what is not a real image at all, it does not narrow the allow-list above.
# still be caught before it reaches the worker as a bare 500 (#15). TIFF is
# included even though the allow-list does not accept it (PIL, and so the
# worker, can decode it; browsers do not show it in the result); BMP is now
# also allowed (#99). This check only refuses what is not a real image at all,
# it does not narrow the allow-list above.
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
(b"\x89PNG\r\n\x1a\n",), # PNG
(b"\xff\xd8\xff",), # JPEG
@@ -83,7 +91,7 @@ async def _read_image(image: UploadFile) -> bytes:
is in hand - the same two-step convention `files.py::upload_file` already
uses, reused rather than reinvented here.
"""
if image.content_type not in ALLOWED_IMAGE_TYPES:
if image.content_type not in _VISION_IMAGE_TYPES: # #99
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {image.content_type} not allowed. Must be an image.",
@@ -164,6 +172,22 @@ async def execute(
)
@router.post("/check", response_model=VisionCheckResponse)
async def check(
task_id: int = Form(...),
image: UploadFile = File(...),
production_run_id: int | None = Form(None, gt=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> VisionCheckResponse:
"""#97: analisi di un frame senza salvare nulla (ne' risultato ne' misure)."""
task = await _task_or_404(db, task_id)
report = await vision_service.check_task(
db, task, await _read_image(image), production_run_id=production_run_id,
)
return VisionCheckResponse(**report)
@router.get("/results/{result_id}/image")
async def get_result_image(
result_id: int,
@@ -0,0 +1,31 @@
"""recipe_tasks.acquisition_json (#97)
recipe_tasks.acquisition_json: camera e modo di analisi scelti nel task,
{"camera_url", "camera_source", "modo": "impulsiva" | "in_linea"}; NULL = la
camera della stazione dell'operatore, analisi impulsiva (come prima).
Revision ID: 021_task_acquisition
Revises: 020_station_camera
Create Date: 2026-10-24
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '021_task_acquisition'
down_revision: Union[str, None] = '020_station_camera'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# batch_alter_table come nelle migrazioni vicine: serve a SQLite
with op.batch_alter_table('recipe_tasks') as batch:
batch.add_column(sa.Column('acquisition_json', sa.JSON(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('recipe_tasks') as batch:
batch.drop_column('acquisition_json')
+29 -1
View File
@@ -2,8 +2,9 @@
import re
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from src.backend.models.api.station import _normalize_http_url
from src.backend.models.orm.task import DEFAULT_LOOP_TASK_TYPES, TASK_TYPES
# Kept in step with the ORM enum so the two cannot drift apart.
@@ -159,6 +160,30 @@ class SubtaskResponse(BaseModel):
vision_output: Optional[str] = None
class AcquisitionConfig(BaseModel):
"""#97: camera (servizio + sorgente) e modo di analisi scelti nel task."""
camera_url: str = Field(..., max_length=500)
camera_source: str = Field(..., min_length=1, max_length=200)
modo: Literal["impulsiva", "in_linea"] = "impulsiva"
@field_validator("camera_url")
@classmethod
def _check_camera_url(cls, value: str) -> str:
# stessa normalizzazione delle stazioni; qui il vuoto non e' ammesso
normalized = _normalize_http_url(value, "camera_url")
if normalized is None:
raise ValueError("camera_url must not be empty")
return normalized
@field_validator("camera_source")
@classmethod
def _check_camera_source(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("camera_source must not be empty")
return value
class TaskCreate(BaseModel):
"""Schema for creating a task."""
title: str = Field(..., min_length=1, max_length=255)
@@ -169,6 +194,7 @@ class TaskCreate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
# #53: assente = default dal tipo (misure e check nel ciclo)
in_loop: Optional[bool] = None
at_close: bool = False
@@ -186,6 +212,7 @@ class TaskUpdate(BaseModel):
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
in_loop: Optional[bool] = None
at_close: Optional[bool] = None
fields: Optional[list[dict[str, Any]]] = None
@@ -207,6 +234,7 @@ class TaskResponse(BaseModel):
file_type: Optional[str] = None
annotations_json: Optional[dict[str, Any]] = None
vision_json: Optional[dict] = None
acquisition_json: Optional[AcquisitionConfig] = None # #97
in_loop: bool = False
at_close: bool = False
fields: Optional[list[dict[str, Any]]] = None
+10
View File
@@ -24,6 +24,16 @@ class VisionExecuteResponse(BaseModel):
failures: list[dict] = []
class VisionCheckResponse(BaseModel):
"""What `POST /api/vision/check` returns (#97): the analysis, nothing saved."""
outcome: str | None = None
overlay: list[dict] = []
failures: list[dict] = []
measurements: list[VisionMeasurementResult] = []
# esito della lettura della calibrazione: ok | no_camera | not_calibrated | unreachable
calibration: str
class VisionRecipeOutput(BaseModel):
"""One numeric output a recipe offers to the quotes."""
key: str
+3
View File
@@ -65,6 +65,9 @@ class RecipeTask(Base):
# 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)
# #97: camera e modo di analisi del task, {camera_url, camera_source, modo};
# NULL = camera della stazione corrente, analisi impulsiva.
acquisition_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
# #53: il task fa parte del ciclo di produzione (default dal tipo: misure e
# check si', gli altri no; un task 'note' si puo' mettere nel ciclo a mano).
in_loop: Mapped[bool] = mapped_column(
+2
View File
@@ -117,6 +117,8 @@ async def _copy_tasks_to_version(
vision_json=task.vision_json,
# Cartella immutabile: basta il riferimento (#46).
vision_recipe_dir=task.vision_recipe_dir,
# #97: camera e modo di analisi seguono il task nella nuova versione.
acquisition_json=task.acquisition_json,
# #53 #55: ciclo, chiusura e campi extra seguono il task nella nuova versione.
in_loop=task.in_loop,
at_close=task.at_close,
+122 -14
View File
@@ -231,35 +231,36 @@ CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore
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).
async def fetch_calibration(
camera_url: str | None, camera_source: str | None, label: str = "",
) -> tuple[dict | None, str]:
"""Legge la calibrazione da un acquisitore (C1, #83; generalizzata in #97).
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.
Un px_per_mm non finito o <= 0 conta come non calibrata. `label` serve
solo ai log (stazione o task).
"""
if station is None or not station.camera_url or not station.camera_source:
if not camera_url or not camera_source:
return None, CAL_NO_CAMERA
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
url = f"{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})
response = await client.get(url, params={"source": camera_source})
except httpx.HTTPError as exc:
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
logger.warning("acquisitore %s non raggiungibile: %s", label, 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,
)
logger.warning("acquisitore %s: HTTP %s", label, response.status_code)
return None, CAL_UNREACHABLE
try:
body = response.json()
except ValueError:
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
logger.warning("acquisitore %s: risposta non JSON", label)
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):
@@ -267,6 +268,33 @@ async def fetch_station_calibration(station: Station | None) -> tuple[dict | Non
return body, CAL_OK
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
"""Calibrazione dall'acquisitore della stazione (C1, #83): vedi fetch_calibration."""
if station is None:
return None, CAL_NO_CAMERA
return await fetch_calibration(
station.camera_url, station.camera_source, f"della stazione {station.code}",
)
def task_camera(task: RecipeTask, station: Station | None) -> tuple[str | None, str | None]:
"""Camera di un task (#97): acquisition_json, altrimenti quella della stazione."""
acquisition = task.acquisition_json
if isinstance(acquisition, dict) and acquisition.get("camera_url") and acquisition.get("camera_source"):
return acquisition["camera_url"], acquisition["camera_source"]
if station is not None:
return station.camera_url, station.camera_source
return None, None
async def fetch_task_calibration(
task: RecipeTask, station: Station | None,
) -> tuple[dict | None, str]:
"""Calibrazione della camera del task (#97), stessi esiti CAL_* di fetch_calibration."""
camera_url, camera_source = task_camera(task, station)
return await fetch_calibration(camera_url, camera_source, f"del task {task.id}")
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)
@@ -498,14 +526,15 @@ 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
# Calibrazione letta dall'acquisitore (#83); None = non c'e'. #97: la camera e'
# quella del task se la ricetta ne sceglie una, altrimenti quella della stazione.
station = 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)
calibration, _ = await fetch_task_calibration(task, station)
if not task.vision_json:
raise HTTPException(
@@ -623,6 +652,85 @@ async def execute_task(
return result, saved, report
async def check_task(
db: AsyncSession,
task: RecipeTask,
image_bytes: bytes,
*,
production_run_id: int | None = None,
) -> dict:
"""Esegue il grafo come execute_task ma NON salva nulla (#97).
Anteprima per l'operatore (ingresso nel task, ciclo «in linea»): niente
VisionResult, niente file, niente misure. L'esito per quota usa
calculate_pass_fail sulle quote con vision_output, nell'ordine di
marker_number come execute. Le quote che il grafo non produce (pezzo non
ancora in posizione) restano fuori da `measurements` e il perche' sta in
`failures`: un'anteprima non deve dare 422 a ogni frame.
"""
if task.task_type not in VISION_TASK_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"task type {task.task_type!r} is not a camera measurement",
)
station = None
station_url = None
if production_run_id is not None:
# stesse regole di execute: 404 / 422 / 409 sulla sessione di produzione
run = await measurement_service.validate_run_for_version(
db, production_run_id, task.version_id,
)
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
if not task.vision_json:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="this task carries no vision graph",
)
quotes = (await db.execute(
select(RecipeSubtask)
.where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output.is_not(None))
.order_by(RecipeSubtask.marker_number)
)).scalars().all()
if not quotes:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="no quote on this task names a vision output",
)
# stessa calibrazione di execute: camera del task, altrimenti della stazione
calibration, cal_reason = await fetch_task_calibration(task, station)
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,
**worker_kwargs,
)
outputs = report["outputs"]
measurements = [
{
"subtask_id": q.id,
"value": float(outputs[q.vision_output]),
"pass_fail": measurement_service.calculate_pass_fail(
float(outputs[q.vision_output]), q,
)[0],
}
for q in quotes if _is_measurable(outputs.get(q.vision_output))
]
return {
"outcome": report.get("outcome"),
"overlay": report.get("overlay") or [],
"failures": report.get("failures") or [],
"measurements": measurements,
"calibration": cal_reason,
}
def _reference_file_path(reference: VisionReferenceImage) -> Path:
return settings.upload_path / reference.path
+19
View File
@@ -341,3 +341,22 @@ class TestStationCameraMigration:
assert "vision_worker_url" in cols("stations") # la 019 resta
command.upgrade(cfg, "head") # e di nuovo su
assert {"camera_url", "camera_source"} <= cols("stations")
class TestTaskAcquisitionMigration:
"""021_task_acquisition: recipe_tasks.acquisition_json, su e giu (#97)."""
def test_021_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def cols(table):
return {c["name"] for c in _inspect(db_path).get_columns(table)}
command.upgrade(cfg, "head")
assert "acquisition_json" in cols("recipe_tasks")
command.downgrade(cfg, "020_station_camera")
assert "acquisition_json" not in cols("recipe_tasks")
assert "camera_url" in cols("stations") # la 020 resta
command.upgrade(cfg, "head") # e di nuovo su
assert "acquisition_json" in cols("recipe_tasks")
+308
View File
@@ -0,0 +1,308 @@
"""#97: camera e modo di analisi nel task, POST /api/vision/check senza salvataggio.
Il worker e l'acquisitore non girano nei test: si fingono (fixture di
test_vision_dxf_compare e test_vision_station_worker).
"""
import pytest
from sqlalchemy import func, select
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
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
from src.backend.tests.test_vision_copy_on_write import (
_current_task_and_subtask, _seed_measurement,
)
from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture acquisitore)
CAL, CAMERA_URL, acquisitore, redirect_uploads,
)
from src.backend.tests.test_vision_execute import an_image
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
_camera_task, _execute, _station_with_run, worker_calls,
)
TASK_CAMERA = "http://10.0.0.50:8300"
ACQ = {"camera_url": TASK_CAMERA, "camera_source": "camera:601020", "modo": "in_linea"}
async def _task_with_acquisition(db_session, user_id, acquisition=ACQ):
recipe, task = await _camera_task(db_session, user_id)
task.acquisition_json = acquisition
await db_session.commit()
return recipe, task
# --- modello e API del task ---------------------------------------------------
@pytest.mark.asyncio
async def test_create_task_stores_and_normalizes_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={
"title": "Camera", "task_type": "camera_measure",
"acquisition_json": {"camera_url": " http://h:1/ ", "camera_source": " camera:1 "},
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
# url normalizzato come nelle stazioni, sorgente senza spazi, modo di default
expected = {"camera_url": "http://h:1", "camera_source": "camera:1", "modo": "impulsiva"}
assert body["acquisition_json"] == expected
db_session.expunge_all()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.id == body["id"])
)).scalar_one()
assert task.acquisition_json == expected # sul DB, non solo nella risposta
got = await client.get(f"/api/tasks/{body['id']}", headers=auth_headers(maker_user))
assert got.json()["acquisition_json"] == expected
@pytest.mark.asyncio
async def test_task_without_acquisition_is_null(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.get(f"/api/recipes/{recipe.id}/tasks", headers=auth_headers(maker_user))
assert resp.json()[0]["acquisition_json"] is None
@pytest.mark.asyncio
@pytest.mark.parametrize("bad", [
{"camera_url": "ftp://h", "camera_source": "camera:1"},
{"camera_url": " ", "camera_source": "camera:1"},
{"camera_url": "http://h", "camera_source": " "},
{"camera_url": "http://h", "camera_source": "x" * 201},
{"camera_url": "http://h", "camera_source": "camera:1", "modo": "continua"},
{"camera_source": "camera:1"},
])
async def test_invalid_acquisition_is_422(client, db_session, maker_user, bad):
recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.post(
f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user),
json={"title": "Camera", "task_type": "camera_measure", "acquisition_json": bad},
)
assert resp.status_code == 422, resp.text
@pytest.mark.asyncio
async def test_update_sets_and_clears_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
resp = await client.put(
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": ACQ},
)
assert resp.status_code == 200, resp.text
assert resp.json()["acquisition_json"] == ACQ
resp = await client.put(
f"/api/tasks/{task.id}", headers=auth_headers(maker_user), json={"acquisition_json": None},
)
assert resp.status_code == 200
assert resp.json()["acquisition_json"] is None # null = camera della stazione
@pytest.mark.asyncio
async def test_copy_on_write_preserves_acquisition(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await _current_task_and_subtask(db_session, recipe.id)
task.acquisition_json = ACQ
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": "forza il copy-on-write"},
)
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.acquisition_json == ACQ
# --- calibrazione dalla camera del task ----------------------------------------
@pytest.mark.asyncio
async def test_execute_reads_calibration_from_the_task_camera(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
# stazione con camera propria: la camera del task prevale
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 acquisitore["urls"] == [(f"{TASK_CAMERA}/camera/calibration", {"source": "camera:601020"})]
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.calibration_snapshot["px_per_mm"] == CAL["px_per_mm"]
@pytest.mark.asyncio
async def test_execute_task_camera_works_without_a_run(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
await _execute(client, measurement_tec_user, task) # niente sessione di produzione
assert acquisitore["urls"][0][0] == f"{TASK_CAMERA}/camera/calibration"
assert "calibration" in worker_calls[0]
@pytest.mark.asyncio
async def test_execute_falls_back_to_the_station_camera(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id) # acquisition_json null
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 acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
assert worker_calls[0]["calibration"] == {"px_per_mm": CAL["px_per_mm"]}
# --- POST /api/vision/check ----------------------------------------------------
async def _check(client, user, task, run=None):
data = {"task_id": str(task.id)}
if run is not None:
data["production_run_id"] = str(run.id)
return await client.post(
"/api/vision/check", headers=auth_headers(user),
files={"image": ("p.png", an_image(), "image/png")}, data=data,
)
async def _counts(db_session):
return tuple([
(await db_session.execute(select(func.count()).select_from(model))).scalar_one()
for model in (Measurement, VisionResult)
])
@pytest.mark.asyncio
async def test_check_saves_nothing_and_reports_pass_fail(
client, db_session, measurement_tec_user, monkeypatch, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
seen = {}
async def fake_call(image_bytes, graph, **kwargs):
seen.update(kwargs)
return {"outputs": {"t1.diameter": 11.0}, "failures": [{"tool_name": "t"}],
"overlay": [{"kind": "line"}], "outcome": "fail",
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
before = await _counts(db_session)
resp = await _check(client, measurement_tec_user, task)
assert resp.status_code == 200, resp.text
body = resp.json()
quote = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().one()
assert body == {
"outcome": "fail", "overlay": [{"kind": "line"}], "failures": [{"tool_name": "t"}],
"measurements": [{"subtask_id": quote.id, "value": 11.0, "pass_fail": "fail"}],
"calibration": "ok",
}
assert seen["calibration"] == {"px_per_mm": CAL["px_per_mm"]} # stessa calibrazione di execute
assert await _counts(db_session) == before # niente misure ne' VisionResult
assert not list(settings.upload_path.rglob("*.overlay.json")) # ne' file
@pytest.mark.asyncio
@pytest.mark.parametrize("value,expected", [(10.0, "pass"), (10.4, "warning"), (9.0, "fail")])
async def test_check_pass_fail_levels(
client, db_session, measurement_tec_user, monkeypatch, value, expected,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
async def fake_call(image_bytes, graph, **kwargs):
return {"outputs": {"t1.diameter": value}, "failures": [], "engine_version": "e" * 40}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await _check(client, measurement_tec_user, task)
assert resp.json()["measurements"][0]["pass_fail"] == expected
assert resp.json()["calibration"] == "no_camera" # senza camera ne' stazione
@pytest.mark.asyncio
async def test_check_calibration_reasons(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
_, task = await _task_with_acquisition(db_session, measurement_tec_user.id)
acquisitore["cal"] = None
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "not_calibrated"
acquisitore["cal"] = "down"
assert (await _check(client, measurement_tec_user, task)).json()["calibration"] == "unreachable"
assert "calibration" not in worker_calls[-1] # senza calibrazione la ricetta usa la sua
@pytest.mark.asyncio
async def test_check_omits_quotes_the_graph_did_not_produce(
client, db_session, measurement_tec_user, monkeypatch,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
async def fake_call(image_bytes, graph, **kwargs):
return {"outputs": {}, "failures": [{"tool_name": "blob"}], "engine_version": "e" * 40}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
resp = await _check(client, measurement_tec_user, task)
assert resp.status_code == 200 # un'anteprima non da' 422 a ogni frame
assert resp.json()["measurements"] == []
assert resp.json()["failures"] == [{"tool_name": "blob"}]
@pytest.mark.asyncio
async def test_check_422_without_graph_or_quotes_or_vision_type(
client, db_session, measurement_tec_user, worker_calls,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
task.vision_json = None
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
quote = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().one()
quote.vision_output = None
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
task.task_type = "measure"
await db_session.commit()
assert (await _check(client, measurement_tec_user, task)).status_code == 422
@pytest.mark.asyncio
async def test_check_with_run_uses_station_worker_and_station_camera(
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, "http://10.0.0.7:8100")
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
before = await _counts(db_session)
resp = await _check(client, measurement_tec_user, task, run)
assert resp.status_code == 200, resp.text
assert worker_calls[0]["worker_url"] == "http://10.0.0.7:8100"
assert acquisitore["urls"][0][0] == f"{CAMERA_URL}/camera/calibration"
assert await _counts(db_session) == before
@pytest.mark.asyncio
async def test_check_requires_login(client, db_session, measurement_tec_user, worker_calls):
_, task = await _camera_task(db_session, measurement_tec_user.id)
resp = await client.post(
"/api/vision/check", files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert resp.status_code in (401, 403)
+109
View File
@@ -0,0 +1,109 @@
"""#99: the vision routes accept BMP (the Opto bench cameras save it); TIFF and
non-images stay refused. files.py (attachments) is not touched by this."""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_upload_limits import ( # noqa: F401 - fixtures
recipe_parts, redirect_uploads, worker_says,
)
def a_bmp() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="BMP")
return buffer.getvalue()
def a_tiff() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="TIFF")
return buffer.getvalue()
async def a_camera_task(db_session, user):
recipe = await create_test_recipe(db_session, user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
await db_session.commit()
return task
@pytest.mark.asyncio
@pytest.mark.parametrize("content_type", ["image/bmp", "image/x-ms-bmp"])
async def test_preview_accepts_a_bmp(
client, db_session, maker_user, worker_says, content_type,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", a_bmp(), content_type)},
data={"task_id": str(task.id)},
)
assert response.status_code != 400, response.text
assert response.status_code != 422, response.text
@pytest.mark.asyncio
async def test_preview_still_rejects_a_tiff(
client, db_session, maker_user, worker_says,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.tif", a_tiff(), "image/tiff")},
data={"task_id": str(task.id)},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_a_text_file_declared_bmp_is_refused_on_its_bytes(
client, db_session, maker_user, worker_says,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", b"i am not an image", "image/bmp")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_reference_image_bmp_is_saved_with_the_bmp_extension(
client, db_session, maker_user,
):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
await db_session.commit()
response = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", a_bmp(), "image/x-ms-bmp")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200, response.text
saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
assert saved.path.endswith(".bmp")
assert (settings.upload_path / saved.path).exists()
@@ -541,6 +541,55 @@ def api_list_stations_for_maker():
return jsonify(out), 200
def _camera_services() -> list[dict]:
"""#97: servizi di acquisizione distinti delle stazioni che hanno una camera_url."""
services: dict[str, list[str]] = {}
for st in _stations_for_maker():
url = (st.get("camera_url") or "").strip().rstrip("/")
if not url:
continue
label = f"{st.get('code') or ''} - {st.get('name') or ''}"
services.setdefault(url, []).append(label)
return [{"camera_url": u, "stazioni": s} for u, s in services.items()]
@maker_bp.route("/api/camera/servizi", methods=["GET"])
@login_required
@maker_or_admin_required
def api_camera_services():
"""#97: servizi di acquisizione fra cui scegliere la camera di un task."""
return jsonify({"servizi": _camera_services()}), 200
@maker_bp.route("/api/camera/sorgenti", methods=["GET"])
@login_required
@maker_or_admin_required
def api_camera_sources():
"""#97: proxy di GET {url}/camera/sources; solo per i servizi delle stazioni."""
url = (request.args.get("url") or "").strip().rstrip("/")
# Niente richieste verso indirizzi arbitrari: url ammesso solo se e' un servizio noto
if not url or url not in {s["camera_url"] for s in _camera_services()}:
return jsonify({
"error": True,
"detail": _("Servizio di acquisizione non ammesso"),
}), 400
try:
resp = http_requests.get(f"{url}/camera/sources", timeout=15)
body = resp.json() if resp.status_code == 200 else None
except (http_requests.RequestException, ValueError) as exc:
return jsonify({
"error": True,
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
sources = body.get("sources") if isinstance(body, dict) else None
if not isinstance(sources, list):
return jsonify({
"error": True,
"detail": _("Risposta del servizio camera non valida (%(status)s)", status=resp.status_code),
}), 502
return jsonify({"sources": sources}), 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
@@ -0,0 +1,70 @@
// «Acquisizione» dell'editor task (#97): camera (servizio + sorgente) e modo di analisi
// («impulsiva» / «in linea») dei task camera_measure e dxf_compare.
// Legge /maker/api/camera/servizi e /maker/api/camera/sorgenti?url=. Lo stato scelto sta
// nel componente padre (editTaskData.acquisition): questo componente carica solo gli elenchi.
// Uso: x-data="taskAcquisition(() => editTaskData.acquisition, msgs)". Dipende da Alpine.js.
function taskAcquisition(getAcq, msgs) {
return {
msgs: msgs || {},
servizi: [],
sources: [],
loading: false,
error: '',
async init() {
await this.loadServizi();
// Task con camera gia' scelta: elenco sorgenti del suo servizio
const acq = getAcq();
if (acq && acq.camera_url) await this.loadSources(acq.camera_url);
},
async loadServizi() {
try {
const resp = await fetch('/maker/api/camera/servizi');
if (resp.ok) this.servizi = (await resp.json()).servizi || [];
} catch (e) { console.error('taskAcquisition servizi', e); }
},
// Servizio salvato nel task ma non piu' fra quelli delle stazioni: resta selezionabile
serviziConCorrente() {
const acq = getAcq();
const out = this.servizi.slice();
if (acq && acq.camera_url && !out.some(s => s.camera_url === acq.camera_url)) {
out.push({ camera_url: acq.camera_url, stazioni: [] });
}
return out;
},
// Cambio servizio: la sorgente scelta non vale piu'
async onServizio() {
const acq = getAcq();
acq.camera_source = '';
this.sources = [];
this.error = '';
if (acq.camera_url) await this.loadSources(acq.camera_url);
},
async loadSources(url) {
this.loading = true;
this.error = '';
try {
const resp = await fetch('/maker/api/camera/sorgenti?url=' + encodeURIComponent(url));
const data = await resp.json().catch(() => ({}));
if (resp.ok) this.sources = data.sources || [];
else this.error = data.detail || this.msgs.genericError || '';
} catch (e) {
this.error = this.msgs.genericError || String(e);
} finally { this.loading = false; }
},
// Sorgente salvata ma non piu' elencata dal servizio: resta visibile
sourcesConCorrente() {
const acq = getAcq();
const out = this.sources.slice();
if (acq && acq.camera_source && !out.some(s => s.name === acq.camera_source)) {
out.push({ name: acq.camera_source, label: acq.camera_source });
}
return out;
}
};
}
@@ -547,6 +547,71 @@
<div class="tmf-card-body space-y-4">
<!-- ========== Acquisizione (#97): camera e modo di analisi, 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)]">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Acquisizione') }}
</p>
<!-- Lettura: camera e modo salvati nel task -->
<template x-if="editingTask !== task.id">
<div class="text-xs space-y-1">
<template x-if="task.acquisition_json && task.acquisition_json.camera_url">
<div class="space-y-1">
<p><span class="text-[var(--text-secondary)]">{{ _('Servizio di acquisizione') }}:</span>
<span class="font-mono" x-text="task.acquisition_json.camera_url"></span></p>
<p><span class="text-[var(--text-secondary)]">{{ _('Sorgente') }}:</span>
<span class="font-mono" x-text="task.acquisition_json.camera_source"></span></p>
<p><span class="text-[var(--text-secondary)]">{{ _('Modo di analisi') }}:</span>
<span x-text="task.acquisition_json.modo === 'in_linea' ? {{ _('Analisi in linea')|tojson_attr }} : {{ _('Analisi impulsiva')|tojson_attr }}"></span></p>
</div>
</template>
<p x-show="!(task.acquisition_json && task.acquisition_json.camera_url)" class="text-[var(--text-muted)]">
{{ _("Camera della stazione dell'operatore") }}
</p>
</div>
</template>
<!-- Modifica: servizio, sorgente, modo (salvati con «Salva» del task) -->
<template x-if="editingTask === task.id">
<div class="space-y-3"
x-data="taskAcquisition(() => editTaskData.acquisition, {{ {'genericError': _('Errore durante il caricamento delle sorgenti')}|tojson_attr }})">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Servizio di acquisizione') }}</label>
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_url" @change="onServizio()">
<option value="">{{ _("Camera della stazione dell'operatore") }}</option>
<template x-for="sv in serviziConCorrente()" :key="sv.camera_url">
<option :value="sv.camera_url"
x-text="sv.camera_url + (sv.stazioni.length ? ' (' + sv.stazioni.join(', ') + ')' : '')"></option>
</template>
</select>
</div>
<div x-show="editTaskData.acquisition.camera_url">
<label class="tmf-label text-xs">{{ _('Sorgente') }}</label>
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_source" :disabled="loading">
<option value="">{{ _('Scegli la sorgente') }}</option>
<template x-for="src in sourcesConCorrente()" :key="src.name">
<option :value="src.name" x-text="src.label || src.name"></option>
</template>
</select>
</div>
</div>
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
<div x-show="editTaskData.acquisition.camera_url" class="flex flex-wrap items-center gap-4 text-xs">
<label class="flex items-center gap-2">
<input type="radio" :name="'acq-modo-' + task.id" value="impulsiva" x-model="editTaskData.acquisition.modo">
{{ _('Analisi impulsiva') }}
</label>
<label class="flex items-center gap-2">
<input type="radio" :name="'acq-modo-' + task.id" value="in_linea" x-model="editTaskData.acquisition.modo">
{{ _('Analisi in linea') }}
</label>
</div>
</div>
</template>
</div>
</template>
<!-- ========== 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)]"
@@ -1335,6 +1400,7 @@
<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='js/task_acquisition.js') }}"></script>{# #97 #}
<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>
@@ -1389,7 +1455,7 @@ function taskEditor() {
// ---- Task CRUD state ----
editingTask: null,
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
editTaskData: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }, // #97
newTask: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
MAX_NOTE_FIELDS: 20, // #55: limite campi per nota (stesso del backend)
_fieldUid: 0,
@@ -1575,6 +1641,18 @@ function taskEditor() {
this.newTask = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
},
// #97: acquisition_json del task; null = camera della stazione (voce vuota o tipo senza camera)
_acquisitionPayload(obj) {
const a = obj.acquisition || {};
const usesCamera = obj.task_type === 'camera_measure' || obj.task_type === 'dxf_compare';
if (!usesCamera || !a.camera_url) return { ok: true, value: null };
if (!a.camera_source) {
this.errorMessage = {{ _("Scegli la sorgente della camera")|tojson }};
return { ok: false };
}
return { ok: true, value: { camera_url: a.camera_url, camera_source: a.camera_source, modo: a.modo === 'in_linea' ? 'in_linea' : 'impulsiva' } };
},
// ---- #53 #55: ciclo di misura e campi della nota ----
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
@@ -1785,7 +1863,13 @@ function taskEditor() {
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
in_loop_touched: false,
at_close: !!task.at_close,
fields: this._fieldsToState(task.fields)
fields: this._fieldsToState(task.fields),
// #97: camera e modo di analisi del task (acquisition_json), vuoti = camera della stazione
acquisition: {
camera_url: (task.acquisition_json && task.acquisition_json.camera_url) || '',
camera_source: (task.acquisition_json && task.acquisition_json.camera_source) || '',
modo: (task.acquisition_json && task.acquisition_json.modo) || 'impulsiva'
}
};
// Ensure expanded
this.expandedTask = task.id;
@@ -1793,7 +1877,7 @@ function taskEditor() {
cancelEditTask() {
this.editingTask = null;
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }; // #97
},
async updateTask(taskId) {
@@ -1801,6 +1885,8 @@ function taskEditor() {
this.errorMessage = '';
const fp = this._fieldsPayload(this.editTaskData); // #55
if (!fp.ok) return;
const acq = this._acquisitionPayload(this.editTaskData); // #97
if (!acq.ok) return;
this.saving = true;
try {
@@ -1818,7 +1904,8 @@ function taskEditor() {
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
in_loop: !!this.editTaskData.in_loop,
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
fields: fp.fields
fields: fp.fields,
acquisition_json: acq.value // #97
})
});
@@ -0,0 +1,140 @@
"""gitea:Adriano/TieMeasureFlow#97 - camera e modo di analisi nel task visione (lato Maker).
api_client e requests sono finti: nessun servizio camera vero.
"""
import json
import re
from unittest.mock import MagicMock, patch
import requests
from tests.conftest import api_get_router
ST1 = {"id": 4, "code": "ST-4", "name": "Banco", "camera_url": "http://acq:8765/", "camera_source": "camera:1"}
ST2 = {"id": 5, "code": "ST-5", "name": "Linea", "camera_url": "http://acq:8765", "camera_source": "camera:2"}
ST3 = {"id": 6, "code": "ST-6", "name": "Altra", "camera_url": "http://altro:9000", "camera_source": "x"}
NO_CAMERA = {"id": 7, "code": "ST-7", "name": "Senza", "camera_url": None, "camera_source": None}
SOURCES = {"sources": [
{"name": "camera:601020", "kind": "camera", "label": "Basler 601020"},
{"name": "pezzo.png", "kind": "image", "label": "immagine - pezzo.png"},
]}
def _stations(mock_api_client, stations=(ST1, ST2, ST3, NO_CAMERA)):
mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)})
def _fake(status=200, body=None):
resp = MagicMock(status_code=status, text="")
if body is None:
resp.json.side_effect = ValueError("no json")
else:
resp.json.return_value = body
return resp
class TestCameraServizi:
def test_distinct_urls_with_stations(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp = logged_in_client.get("/maker/api/camera/servizi")
assert resp.status_code == 200
assert resp.get_json() == {"servizi": [
{"camera_url": "http://acq:8765", "stazioni": ["ST-4 - Banco", "ST-5 - Linea"]},
{"camera_url": "http://altro:9000", "stazioni": ["ST-6 - Altra"]},
]}
def test_no_stations_with_camera(self, logged_in_client, mock_api_client):
_stations(mock_api_client, [NO_CAMERA])
assert logged_in_client.get("/maker/api/camera/servizi").get_json() == {"servizi": []}
class TestCameraSorgenti:
def test_lists_sources(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get", return_value=_fake(200, SOURCES)) as get:
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765/")
assert resp.status_code == 200
assert resp.get_json() == SOURCES
args, kwargs = get.call_args
assert args[0] == "http://acq:8765/camera/sources"
assert kwargs["timeout"] == 15
def test_url_not_a_station_service_is_400(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/camera/sorgenti?url=http://evil:1")
assert resp.status_code == 400
get.assert_not_called()
def test_missing_url_is_400(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get") as get:
assert logged_in_client.get("/maker/api/camera/sorgenti").status_code == 400
get.assert_not_called()
def test_service_down_is_502(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get",
side_effect=requests.ConnectionError("refused")):
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765")
assert resp.status_code == 502
assert "refused" in resp.get_json()["detail"]
def test_service_error_status_is_502(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get", return_value=_fake(500, {"detail": "x"})):
resp = logged_in_client.get("/maker/api/camera/sorgenti?url=http://acq:8765")
assert resp.status_code == 502
class TestTaskProxyForwardsAcquisition:
def test_put_forwards_acquisition_json(self, logged_in_client, mock_api_client):
acq = {"camera_url": "http://acq:8765", "camera_source": "camera:1", "modo": "in_linea"}
mock_api_client.put.return_value = {"id": 11, "title": "T", "acquisition_json": acq}
resp = logged_in_client.put("/maker/api/tasks/11", json={"title": "T", "acquisition_json": acq})
assert resp.status_code == 200
assert mock_api_client.put.call_args.kwargs["data"]["acquisition_json"] == acq
def test_put_forwards_null(self, logged_in_client, mock_api_client):
mock_api_client.put.return_value = {"id": 11, "title": "T", "acquisition_json": None}
logged_in_client.put("/maker/api/tasks/11", json={"title": "T", "acquisition_json": None})
assert mock_api_client.put.call_args.kwargs["data"]["acquisition_json"] is None
class TestTaskEditorAcquisitionUi:
def _html(self, client, mock_api_client, task_type="camera_measure"):
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,
"acquisition_json": {"camera_url": "http://acq:8765", "camera_source": "camera:1",
"modo": "in_linea"},
}],
})
return client.get("/maker/recipes/3/tasks").get_data(as_text=True)
def test_section_and_fields(self, logged_in_client, mock_api_client):
html = self._html(logged_in_client, mock_api_client)
assert "taskAcquisition(" in html
assert "js/task_acquisition.js" in html
assert "Servizio di acquisizione" in html
assert "Sorgente" in html
assert "Analisi impulsiva" in html and "Analisi in linea" in html
assert 'value="impulsiva"' in html and 'value="in_linea"' in html
assert "Camera della stazione dell&#39;operatore" in html or \
"Camera della stazione dell'operatore" in html
def test_payload_carries_acquisition_json(self, logged_in_client, mock_api_client):
html = self._html(logged_in_client, mock_api_client)
assert "acquisition_json: acq.value" in html
assert "_acquisitionPayload(this.editTaskData)" in html
# il task salvato riempie lo stato di modifica
assert "task.acquisition_json.camera_url" in html
def test_static_js_endpoints(self):
import os
path = os.path.join(os.path.dirname(__file__), "..", "static", "js", "task_acquisition.js")
src = open(path, encoding="utf-8").read()
assert "/maker/api/camera/servizi" in src
assert "/maker/api/camera/sorgenti?url=" in src