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:
2026-10-06 11:27:23 +02:00
parent 97c49f2b5b
commit 70e2072b79
6 changed files with 197 additions and 15 deletions
+5 -1
View File
@@ -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 -2
View File
@@ -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)
# ---------------------------------------------------------------------------
+21
View File
@@ -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(...),