fix(vision): execute controlla task_type, versione, ricetta attiva, produzione (#38.7)

execute_task rifiuta 422 se il task non è camera_measure, 409 se la
versione non è corrente o la ricetta non è attiva; production_run_id,
quando presente, è validato con measurement_service.validate_run_for_version
(stessa regola di una lettura manuale). Router: lot_number/serial_number
max_length=100, production_run_id gt=0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:41:22 +02:00
parent c8caf40af9
commit 1c5292d1e0
3 changed files with 257 additions and 3 deletions
+3 -3
View File
@@ -95,9 +95,9 @@ async def _read_image(image: UploadFile) -> bytes:
async def execute(
task_id: int = Form(...),
image: UploadFile = File(...),
lot_number: str | None = Form(None),
serial_number: str | None = Form(None),
production_run_id: int | None = Form(None),
lot_number: str | None = Form(None, max_length=100),
serial_number: str | None = Form(None, max_length=100),
production_run_id: int | None = Form(None, gt=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_measurement_tec),
) -> VisionExecuteResponse:
+30
View File
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
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
@@ -132,6 +133,35 @@ async def execute_task(
serial_number: str | None = None,
production_run_id: int | None = None,
) -> tuple[VisionResult, list[Measurement]]:
if task.task_type != "camera_measure":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"task type {task.task_type!r} is not a camera measurement",
)
version = (await db.execute(
select(RecipeVersion).where(RecipeVersion.id == version_id)
)).scalar_one_or_none()
if version is None or not version.is_current:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="this task's recipe version is no longer current",
)
recipe = await measurement_service.recipe_of_version(db, version_id)
if recipe is None or not recipe.active:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="this recipe is not active",
)
if production_run_id is not None:
# 404 if the run does not exist, 422 if it belongs to another recipe,
# 409 if it is closed or paused - same rule a manual reading follows.
await measurement_service.validate_run_for_version(
db, production_run_id, version_id,
)
if not task.vision_json:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
@@ -0,0 +1,224 @@
"""#38.7: /api/vision/execute must not run a camera graph against a task that
is not one, a version that is no longer current, an inactive recipe, or a
production run that belongs to someone else's job or is already closed - and
lot_number/serial_number/production_run_id must not reach MySQL unbounded.
"""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.models.api.production import ProductionRunCreate
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.services import production_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
def an_image() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
return buffer.getvalue()
async def recipe_parts(db_session, recipe_id: int):
version = (await db_session.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
task = (await db_session.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
subtask = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
return version, task, subtask
async def a_camera_task(db_session, recipe_id: int):
version, task, subtask = await recipe_parts(db_session, recipe_id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.commit()
await db_session.refresh(task)
await db_session.refresh(subtask)
return version, task, subtask
@pytest.fixture
def worker_says(monkeypatch):
def _install(outputs: dict):
async def fake_call(image_bytes: bytes, graph: dict) -> dict:
return {"outputs": outputs, "failures": [],
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr(
"src.backend.services.vision_service._call_worker", fake_call,
)
return _install
async def _station(db_session, user_id: int, code: str = "ST-VIS") -> Station:
station = Station(code=code, name=f"Stazione {code}", active=True, created_by=user_id)
db_session.add(station)
await db_session.commit()
await db_session.refresh(station)
return station
@pytest.mark.asyncio
async def test_execute_rejects_a_task_that_is_not_a_camera_measurement(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
# left at its fixture default task_type ("measure"), no vision_json
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
assert "camera" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_execute_rejects_a_non_current_version(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await a_camera_task(db_session, recipe.id)
version.is_current = False
await db_session.commit()
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_execute_rejects_an_inactive_recipe(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await a_camera_task(db_session, recipe.id)
recipe.active = False
await db_session.commit()
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id)},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_execute_rejects_a_production_run_of_another_recipe(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-1")
version, task, subtask = await a_camera_task(db_session, recipe.id)
other_recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-2")
station = await _station(db_session, measurement_tec_user.id)
run = await production_service.open_run(
db_session,
ProductionRunCreate(station_code=station.code, recipe_id=other_recipe.id),
measurement_tec_user,
)
await db_session.commit()
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id), "production_run_id": str(run.id)},
)
assert response.status_code == 422
assert "recipe" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_execute_rejects_a_closed_production_run(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id, code="VIS-GATE-3")
version, task, subtask = await a_camera_task(db_session, recipe.id)
station = await _station(db_session, measurement_tec_user.id, code="ST-VIS-2")
run = await production_service.open_run(
db_session,
ProductionRunCreate(station_code=station.code, recipe_id=recipe.id),
measurement_tec_user,
)
await db_session.commit()
await production_service.close_run(
db_session, run, measurement_tec_user, measurement_tec_user,
)
await db_session.commit()
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id), "production_run_id": str(run.id)},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_execute_rejects_a_lot_number_over_the_column_limit(
client, db_session, measurement_tec_user, worker_says,
):
"""max_length=100: a longer value must be refused before it can 500 a
strict MySQL VARCHAR(100) column."""
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await a_camera_task(db_session, recipe.id)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id), "lot_number": "L" * 101},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_execute_rejects_a_non_positive_production_run_id(
client, db_session, measurement_tec_user, worker_says,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
version, task, subtask = await a_camera_task(db_session, recipe.id)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/execute",
headers=auth_headers(measurement_tec_user),
files={"image": ("p.png", an_image(), "image/png")},
data={"task_id": str(task.id), "production_run_id": "0"},
)
assert response.status_code == 422