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:
@@ -95,9 +95,9 @@ async def _read_image(image: UploadFile) -> bytes:
|
|||||||
async def execute(
|
async def execute(
|
||||||
task_id: int = Form(...),
|
task_id: int = Form(...),
|
||||||
image: UploadFile = File(...),
|
image: UploadFile = File(...),
|
||||||
lot_number: str | None = Form(None),
|
lot_number: str | None = Form(None, max_length=100),
|
||||||
serial_number: str | None = Form(None),
|
serial_number: str | None = Form(None, max_length=100),
|
||||||
production_run_id: int | None = Form(None),
|
production_run_id: int | None = Form(None, gt=0),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_measurement_tec),
|
current_user: User = Depends(require_measurement_tec),
|
||||||
) -> VisionExecuteResponse:
|
) -> VisionExecuteResponse:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
from src.backend.models.orm.measurement import Measurement
|
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.task import RecipeSubtask, RecipeTask
|
||||||
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
|
||||||
from src.backend.services import measurement_service
|
from src.backend.services import measurement_service
|
||||||
@@ -132,6 +133,35 @@ async def execute_task(
|
|||||||
serial_number: str | None = None,
|
serial_number: str | None = None,
|
||||||
production_run_id: int | None = None,
|
production_run_id: int | None = None,
|
||||||
) -> tuple[VisionResult, list[Measurement]]:
|
) -> 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:
|
if not task.vision_json:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
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
|
||||||
Reference in New Issue
Block a user