File di riferimento rimossi con il task, immagini animate rifiutate, filtro production_run_id (#43)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -174,6 +174,7 @@ def _build_measurement_filters(
|
||||
date_from: datetime | None,
|
||||
date_to: datetime | None,
|
||||
pass_fail: str | None,
|
||||
production_run_id: int | None = None,
|
||||
) -> list:
|
||||
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
||||
filters = []
|
||||
@@ -199,6 +200,8 @@ def _build_measurement_filters(
|
||||
filters.append(Measurement.measured_at <= date_to)
|
||||
if pass_fail is not None:
|
||||
filters.append(Measurement.pass_fail == pass_fail)
|
||||
if production_run_id is not None:
|
||||
filters.append(Measurement.production_run_id == production_run_id)
|
||||
return filters
|
||||
|
||||
|
||||
@@ -213,6 +216,7 @@ async def get_measurements(
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
|
||||
production_run_id: int | None = Query(None, gt=0),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=500),
|
||||
user: User = Depends(get_current_user),
|
||||
@@ -228,7 +232,7 @@ async def get_measurements(
|
||||
"""
|
||||
filters = _build_measurement_filters(
|
||||
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||
serial_number, date_from, date_to, pass_fail,
|
||||
serial_number, date_from, date_to, pass_fail, production_run_id,
|
||||
)
|
||||
|
||||
# Build query
|
||||
|
||||
@@ -19,7 +19,8 @@ from src.backend.models.api.task import (
|
||||
TaskUpdate,
|
||||
validate_tolerance_order,
|
||||
)
|
||||
from src.backend.services import recipe_service
|
||||
from src.backend.models.orm.vision import VisionReferenceImage
|
||||
from src.backend.services import recipe_service, vision_service
|
||||
|
||||
router = APIRouter(tags=["tasks"])
|
||||
|
||||
@@ -366,8 +367,24 @@ async def delete_task(
|
||||
|
||||
task = await _task_for_write(db, task, user)
|
||||
|
||||
# The rows go with the task (migration 014 cascades them), the files do not:
|
||||
# collect them now, remove the files only once the commit is durable - same
|
||||
# order as vision.py::delete_reference_image. Rows are deleted explicitly so
|
||||
# file_still_referenced sees the post-delete state even where the database
|
||||
# does not enforce the cascade.
|
||||
references = (await db.execute(
|
||||
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
|
||||
)).scalars().all()
|
||||
for reference in references:
|
||||
await db.delete(reference)
|
||||
|
||||
await db.delete(task)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
# A copy-on-write sibling may still point at the same file (#38.6).
|
||||
for reference in references:
|
||||
if not await vision_service.file_still_referenced(db, reference.path):
|
||||
vision_service.remove_reference_file(reference)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -4,7 +4,10 @@ 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 io
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -90,9 +93,27 @@ async def _read_image(image: UploadFile) -> bytes:
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="the file's content does not match a supported image format",
|
||||
)
|
||||
_reject_animated(content)
|
||||
return content
|
||||
|
||||
|
||||
def _reject_animated(content: bytes) -> None:
|
||||
"""Animated GIF/WebP/APNG have no single frame to measure (decision of
|
||||
06/10/2026, #43): refuse them instead of silently reading frame 0."""
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as img:
|
||||
animated = getattr(img, "is_animated", False) or getattr(img, "n_frames", 1) > 1
|
||||
except Exception:
|
||||
# Unreadable here is not this check's business: the magic bytes passed
|
||||
# and the worker decides what it can decode.
|
||||
return
|
||||
if animated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="animated images are not supported",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/execute", response_model=VisionExecuteResponse)
|
||||
async def execute(
|
||||
task_id: int = Form(...),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""#43: reference files removed with their task, animated images refused,
|
||||
GET /api/measurements filtered by production run."""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
from src.backend.config import settings
|
||||
from src.backend.models.orm.measurement import Measurement
|
||||
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 a_png, recipe_parts
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def redirect_uploads(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||
|
||||
|
||||
async def _upload_reference(client, user, task_id):
|
||||
resp = await client.post(
|
||||
"/api/vision/reference-images", headers=auth_headers(user),
|
||||
files={"image": ("p.png", a_png(), "image/png")},
|
||||
data={"task_id": str(task_id)},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_task_removes_the_reference_file(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()
|
||||
await _upload_reference(client, maker_user, task.id)
|
||||
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||
path = settings.upload_path / ref.path
|
||||
assert path.exists()
|
||||
|
||||
resp = await client.delete(f"/api/tasks/{task.id}", headers=auth_headers(maker_user))
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_task_keeps_a_file_another_task_still_references(
|
||||
client, db_session, maker_user,
|
||||
):
|
||||
r1 = await create_test_recipe(db_session, maker_user.id)
|
||||
r2 = await create_test_recipe(db_session, maker_user.id, code="REC-002")
|
||||
_, t1, _ = await recipe_parts(db_session, r1.id)
|
||||
_, t2, _ = await recipe_parts(db_session, r2.id)
|
||||
await db_session.commit()
|
||||
await _upload_reference(client, maker_user, t1.id)
|
||||
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
|
||||
db_session.add(VisionReferenceImage(task_id=t2.id, path=ref.path))
|
||||
await db_session.commit()
|
||||
path = settings.upload_path / ref.path
|
||||
|
||||
resp = await client.delete(f"/api/tasks/{t1.id}", headers=auth_headers(maker_user))
|
||||
|
||||
assert resp.status_code == 204
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def _animated(fmt: str, frames: int) -> bytes:
|
||||
imgs = [Image.new("L", (16, 16), color=i * 40) for i in range(frames)]
|
||||
buf = io.BytesIO()
|
||||
imgs[0].save(buf, format=fmt, save_all=True, append_images=imgs[1:], duration=50)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("fmt,mime,frames,expected", [
|
||||
("GIF", "image/gif", 2, 422),
|
||||
("WEBP", "image/webp", 2, 422),
|
||||
("GIF", "image/gif", 1, 201),
|
||||
])
|
||||
async def test_animated_images_are_refused(
|
||||
client, db_session, maker_user, fmt, mime, frames, expected,
|
||||
):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
_, task, _ = await recipe_parts(db_session, recipe.id)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/vision/reference-images", headers=auth_headers(maker_user),
|
||||
files={"image": ("a", _animated(fmt, frames), mime)},
|
||||
data={"task_id": str(task.id)},
|
||||
)
|
||||
|
||||
if expected == 422:
|
||||
assert resp.status_code == 422
|
||||
assert "animated" in resp.json()["detail"]
|
||||
else:
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_measurements_filter_by_production_run(client, db_session, maker_user):
|
||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||
version, _, subtask = await recipe_parts(db_session, recipe.id)
|
||||
for run in (7, 7, 8, None):
|
||||
db_session.add(Measurement(
|
||||
subtask_id=subtask.id, version_id=version.id, measured_by=maker_user.id,
|
||||
value=1.0, pass_fail="pass", deviation=0.0, input_method="manual",
|
||||
production_run_id=run,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
resp = await client.get(
|
||||
"/api/measurements/", headers=auth_headers(maker_user),
|
||||
params={"production_run_id": 7},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
assert {m["production_run_id"] for m in body["items"]} == {7}
|
||||
Reference in New Issue
Block a user