feat(vision): immagini di riferimento non orfane, GET/DELETE (#38.5)

save_reference_image scrive il file dopo il flush riuscito; il router
lo cancella se il commit successivo fallisce. Aggiunti GET
/api/vision/reference-images?task_id= (lista, Maker) e DELETE
/api/vision/reference-images/{id} (cancella riga e file, Maker).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:43:35 +02:00
parent 5ca3dc14cd
commit 65f6a4b18b
4 changed files with 217 additions and 8 deletions
+60 -4
View File
@@ -260,6 +260,21 @@ async def execute_task(
return result, saved
def _reference_file_path(reference: VisionReferenceImage) -> Path:
return settings.upload_path / reference.path
def remove_reference_file(reference: VisionReferenceImage) -> None:
"""Delete a reference image's file, if it is still there.
Called by the router only once the row's fate is durable: after a
successful commit when the row was just deleted, or to undo a
`save_reference_image` whose row failed to commit (see both call sites).
`missing_ok` because "already gone" must never be an error here.
"""
_reference_file_path(reference).unlink(missing_ok=True)
async def save_reference_image(
db: AsyncSession,
task_id: int,
@@ -276,17 +291,22 @@ async def save_reference_image(
`extension` must match the upload's real content type (the router derives
it from `image.content_type`) - writing a jpeg to disk as `<uuid>.png`
would be a lie the filename tells forever.
The file is written only after the row has flushed successfully - a bad
FK or constraint fails at `flush()`, before anything exists on disk, so
there is nothing to clean up. The caller still has to remove the file if
the *commit* that follows fails (`remove_reference_file`, from the
router): a flush is not durable, and an orphaned file from a rolled-back
row is exactly what #38 found.
"""
# `upload_path` is the resolved Path, `upload_dir` the bare setting: the
# rest of the codebase uses the former (see production_export_service).
folder = settings.upload_path / "vision" / "reference" / str(task_id)
folder.mkdir(parents=True, exist_ok=True)
name = f"{uuid4().hex}.{extension}"
(folder / name).write_bytes(image_bytes)
relative_path = str(Path("vision") / "reference" / str(task_id) / name)
reference = VisionReferenceImage(
task_id=task_id,
path=str(Path("vision") / "reference" / str(task_id) / name),
path=relative_path,
note=note,
station_id=station_id,
device_code=device_code,
@@ -294,10 +314,46 @@ async def save_reference_image(
)
db.add(reference)
await db.flush()
folder = _reference_file_path(reference).parent
folder.mkdir(parents=True, exist_ok=True)
_reference_file_path(reference).write_bytes(image_bytes)
await db.refresh(reference)
return reference
async def list_reference_images(
db: AsyncSession, task_id: int,
) -> list[VisionReferenceImage]:
result = await db.execute(
select(VisionReferenceImage)
.where(VisionReferenceImage.task_id == task_id)
.order_by(VisionReferenceImage.acquired_at)
)
return list(result.scalars().all())
async def delete_reference_image(
db: AsyncSession, reference_id: int,
) -> VisionReferenceImage:
"""Flush the row's deletion. The caller (router) removes the file only
once the commit that makes the deletion durable has actually succeeded -
deleting it any earlier would leave the row pointing at a file that is
already gone if the commit then failed.
"""
reference = (await db.execute(
select(VisionReferenceImage).where(VisionReferenceImage.id == reference_id)
)).scalar_one_or_none()
if reference is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Reference image not found",
)
await db.delete(reference)
await db.flush()
return reference
async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dict:
"""Run the graph and save nothing. Composing is not measuring."""
if not task.vision_json: