diff --git a/backend/app.py b/backend/app.py index e9978ed..dcbf975 100644 --- a/backend/app.py +++ b/backend/app.py @@ -259,5 +259,8 @@ async def post_taxonomy_sync() -> dict[str, Any]: from .slabs import router as slabs_router app.include_router(slabs_router) +from .photos import router as photos_router +app.include_router(photos_router) + # expose for tests __all__ = ["app", "__version__"] diff --git a/backend/photos.py b/backend/photos.py new file mode 100644 index 0000000..34e8e5a --- /dev/null +++ b/backend/photos.py @@ -0,0 +1,116 @@ +"""Photo BLOB storage (S3). Multipart upload of original + processed variants into +slab_photos; GET original/processed while retained; 410 Gone once purged. + +Cites CAPTURE-95/105/130 (lineup order), PUBLISH-40 (processed PNGs, no cap). +""" + +from typing import Any + +from fastapi import APIRouter, File, Form, UploadFile +from fastapi.responses import JSONResponse, Response + +from .db import get_connection + +router = APIRouter(prefix="/api/v1/slabs") + + +def _error(code: str, message: str, detail: Any = None) -> dict: + return {"error": {"code": code, "message": message, "detail": detail}} + + +def _get_slab(conn, slab_id: str): + return conn.execute( + "SELECT id, deleted_at FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,) + ).fetchone() + + +def _get_photo(conn, slab_id: str, photo_id: str): + row = conn.execute( + "SELECT id, original_blob, processed_blob, width_px, height_px, is_calibration, seq " + "FROM slab_photos WHERE id=? AND slab_id=?", + (photo_id, slab_id), + ).fetchone() + return row + + +def _next_seq(conn, slab_id: str) -> int: + row = conn.execute("SELECT COALESCE(MAX(seq),0)+1 FROM slab_photos WHERE slab_id=?", (slab_id,)).fetchone() + return row[0] + + +@router.post("/{slab_id}/photos", status_code=201) +async def upload_photo( + slab_id: str, + photo_id: str = Form(...), + seq: int | None = Form(None), + is_calibration: int = Form(0), + original: UploadFile = File(...), + processed: UploadFile | None = File(None), +) -> dict: + conn = get_connection() + if _get_slab(conn, slab_id) is None: + return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) + if is_calibration not in (0, 1): + return JSONResponse(status_code=400, content=_error("validation", "is_calibration must be 0 or 1")) + orig_bytes = await original.read() + proc_bytes = await processed.read() if processed is not None else None + if not orig_bytes: + return JSONResponse(status_code=400, content=_error("validation", "original photo is empty")) + # Replace any existing photo with the same id on this slab (re-upload). + conn.execute("DELETE FROM slab_photos WHERE id=? AND slab_id=?", (photo_id, slab_id)) + seq_val = seq if seq is not None else _next_seq(conn, slab_id) + conn.execute( + "INSERT INTO slab_photos(id, slab_id, seq, is_calibration, original_blob, processed_blob, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (photo_id, slab_id, seq_val, is_calibration, orig_bytes, proc_bytes, _now()), + ) + conn.commit() + row = _get_photo(conn, slab_id, photo_id) + return {"id": row["id"], "slab_id": slab_id, "seq": row["seq"], "is_calibration": row["is_calibration"], + "original_retained": True, "processed_retained": row["processed_blob"] is not None} + + +def _now() -> str: + from datetime import datetime, timezone + + return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() + + +@router.get("/{slab_id}/photos/{photo_id}/original") +async def get_original(slab_id: str, photo_id: str): + conn = get_connection() + if _get_slab(conn, slab_id) is None: + return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) + row = _get_photo(conn, slab_id, photo_id) + if row is None: + return JSONResponse(status_code=404, content=_error("not_found", "Photo not found")) + if row["original_blob"] is None: + return Response(status_code=410) + return Response(content=row["original_blob"], media_type="image/png") + + +@router.get("/{slab_id}/photos/{photo_id}/processed") +async def get_processed(slab_id: str, photo_id: str): + conn = get_connection() + if _get_slab(conn, slab_id) is None: + return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) + row = _get_photo(conn, slab_id, photo_id) + if row is None: + return JSONResponse(status_code=404, content=_error("not_found", "Photo not found")) + if row["processed_blob"] is None: + return Response(status_code=410) + return Response(content=row["processed_blob"], media_type="image/png") + + +@router.get("/{slab_id}/photos") +async def list_photos(slab_id: str) -> list[dict]: + conn = get_connection() + if _get_slab(conn, slab_id) is None: + return JSONResponse(status_code=404, content=_error("not_found", "Slab not found")) + rows = conn.execute( + "SELECT id, seq, is_calibration, width_px, height_px, " + "(original_blob IS NOT NULL) AS has_original, (processed_blob IS NOT NULL) AS has_processed " + "FROM slab_photos WHERE slab_id=? ORDER BY seq", + (slab_id,), + ).fetchall() + return [dict(r) for r in rows] diff --git a/backend/tests/test_photos_api.py b/backend/tests/test_photos_api.py new file mode 100644 index 0000000..d28dd84 --- /dev/null +++ b/backend/tests/test_photos_api.py @@ -0,0 +1,98 @@ +"""S3 photos BLOB storage tests: multipart upload, original/processed GET, 410 after purge.""" + +import base64 +import os +import shutil +import tempfile + +import pytest +from fastapi.testclient import TestClient + +import backend.db as db_mod + + +@pytest.fixture +def photos_env(): + tmp = tempfile.mkdtemp(prefix="photos_") + os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "p.db") + os.environ["SLAB_IMAGES_DIR"] = tmp + os.environ["WOO_BASE_URL"] = "https://example.com" + os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() + db_mod._reset_for_tests() + from backend.app import app + + conn = db_mod.init_db() + # seed a slab + conn.execute("INSERT INTO slabs(id, sku, status, server_rev, created_at, updated_at) VALUES ('s1','SLAB-01','calibrated',1,'t','t')") + conn.commit() + yield TestClient(app), conn + db_mod._reset_for_tests() + shutil.rmtree(tmp, ignore_errors=True) + + +PNG_ORIG = b"\x89PNG-orig-bytes" +PNG_PROC = b"\x89PNG-proc-bytes" + + +def _multipart(client, slab_id, photo_id, original=PNG_ORIG, processed=PNG_PROC, is_calibration=0): + files = {"original": ("orig.png", original, "image/png")} + if processed is not None: + files["processed"] = ("proc.png", processed, "image/png") + return client.post( + f"/api/v1/slabs/{slab_id}/photos", + data={"photo_id": photo_id, "is_calibration": str(is_calibration)}, + files=files, + ) + + +def test_upload_and_get_original_processed(photos_env): + client, _ = photos_env + r = _multipart(client, "s1", "p1") + assert r.status_code == 201, r.text + body = r.json() + assert body["original_retained"] is True and body["processed_retained"] is True + + r = client.get("/api/v1/slabs/s1/photos/p1/original") + assert r.status_code == 200 + assert r.content == PNG_ORIG + assert r.headers["content-type"] == "image/png" + + r = client.get("/api/v1/slabs/s1/photos/p1/processed") + assert r.status_code == 200 + assert r.content == PNG_PROC + + +def test_processed_410_when_purged(photos_env): + client, conn = photos_env + _multipart(client, "s1", "p2") + # simulate purge (publish clears blobs) + conn.execute("UPDATE slab_photos SET original_blob=NULL, processed_blob=NULL WHERE id='p2'") + conn.commit() + assert client.get("/api/v1/slabs/s1/photos/p2/original").status_code == 410 + assert client.get("/api/v1/slabs/s1/photos/p2/processed").status_code == 410 + + +def test_upload_requires_slab(photos_env): + client, _ = photos_env + r = _multipart(client, "nope", "p1") + assert r.status_code == 404 + + +def test_reupload_replaces(photos_env): + client, _ = photos_env + _multipart(client, "s1", "p3", original=PNG_ORIG, processed=PNG_PROC) + r = _multipart(client, "s1", "p3", original=b"NEW-ORIG", processed=None) + assert r.status_code == 201 + assert r.json()["processed_retained"] is False + assert client.get("/api/v1/slabs/s1/photos/p3/original").content == b"NEW-ORIG" + assert client.get("/api/v1/slabs/s1/photos/p3/processed").status_code == 410 + + +def test_list_photos(photos_env): + client, _ = photos_env + _multipart(client, "s1", "pa", is_calibration=1) + _multipart(client, "s1", "pb") + r = client.get("/api/v1/slabs/s1/photos") + assert r.status_code == 200 + ids = [row["id"] for row in r.json()] + assert "pa" in ids and "pb" in ids diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 137fe25..390a3ce 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -11,6 +11,7 @@ import AxisPanel from './capture/AxisPanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte'; import Settings from './create/settings/Settings.svelte'; import SlabList from './create/slabs/SlabList.svelte'; +import PhotoAttach from './create/photos/PhotoAttach.svelte'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; const session: CaptureSession = createCaptureSession(); @@ -20,6 +21,7 @@ import SlabList from './create/slabs/SlabList.svelte'; let pngUrl: string | null = null; let showSettings = false; let showSlabs = false; + let showPhotos = false; const unsubCap = session.capture.subscribe((c) => { cap = c; @@ -95,6 +97,7 @@ import SlabList from './create/slabs/SlabList.svelte';
{message}
{/if} + + {#if photos.length} +No photos attached.
+ {/if} +