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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user