Add asset preview management endpoints (PUT/DELETE /api/assets/{id}/preview)

- PUT /api/assets/{id}/preview: sets the preview asset for an existing asset,
  returns the full updated Asset object. Returns 404 if asset ID doesn't exist.
- DELETE /api/assets/{id}/preview: clears the preview asset link, returns 204.
  Returns 404 if asset ID doesn't exist.

Added OpenAPI spec entries under the assets tag with no x-runtime restriction.
Implemented handlers using the existing set_asset_preview service function.
Added integration tests covering happy paths and 404 cases.

Co-authored-by: Matt Miller <MillerMedia@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-05-06 23:56:26 +00:00 committed by Matt Miller
parent 65045730a6
commit 9bd5710acb
3 changed files with 233 additions and 0 deletions

View File

@ -36,6 +36,7 @@ from app.assets.services import (
list_tags,
remove_tags,
resolve_asset_for_download,
set_asset_preview,
update_asset_metadata,
upload_from_temp_path,
)
@ -545,6 +546,70 @@ async def delete_asset_route(request: web.Request) -> web.Response:
return web.Response(status=204)
@ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}/preview")
@_require_assets_feature_enabled
async def set_asset_preview_route(request: web.Request) -> web.Response:
reference_id = str(uuid.UUID(request.match_info["id"]))
try:
payload = await request.json()
except Exception:
return _build_error_response(
400, "INVALID_JSON", "Request body must be valid JSON."
)
preview_id = payload.get("preview_id")
if not preview_id:
return _build_error_response(
400, "INVALID_BODY", "preview_id is required."
)
try:
result = set_asset_preview(
reference_id=reference_id,
preview_reference_id=preview_id,
owner_id=USER_MANAGER.get_request_user_id(request),
)
response_payload = _build_asset_response(result)
except PermissionError as pe:
return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
except ValueError as ve:
return _build_error_response(
404, "ASSET_NOT_FOUND", str(ve), {"id": reference_id}
)
except Exception:
logging.exception(
"set_asset_preview failed for reference_id=%s",
reference_id,
)
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
return web.json_response(response_payload.model_dump(mode="json", exclude_none=True), status=200)
@ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}/preview")
@_require_assets_feature_enabled
async def clear_asset_preview_route(request: web.Request) -> web.Response:
reference_id = str(uuid.UUID(request.match_info["id"]))
try:
result = set_asset_preview(
reference_id=reference_id,
preview_reference_id=None,
owner_id=USER_MANAGER.get_request_user_id(request),
)
except PermissionError as pe:
return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id})
except ValueError as ve:
return _build_error_response(
404, "ASSET_NOT_FOUND", str(ve), {"id": reference_id}
)
except Exception:
logging.exception(
"clear_asset_preview failed for reference_id=%s",
reference_id,
)
return _build_error_response(500, "INTERNAL", "Unexpected server error.")
return web.Response(status=204)
@ROUTES.get("/api/tags")
@_require_assets_feature_enabled
async def get_tags(request: web.Request) -> web.Response:

View File

@ -1821,6 +1821,63 @@ paths:
"404":
description: Asset not found
/api/assets/{id}/preview:
put:
operationId: setAssetPreview
tags: [assets]
summary: Set preview asset
description: Sets the preview asset for an existing asset. The preview_id must reference an existing asset.
x-feature-gate: enable-assets
parameters:
- name: id
in: path
description: The asset ID.
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- preview_id
properties:
preview_id:
type: string
format: uuid
description: ID of the asset to use as the preview
responses:
"200":
description: Preview set successfully
content:
application/json:
schema:
$ref: "#/components/schemas/Asset"
"404":
description: Asset not found
delete:
operationId: clearAssetPreview
tags: [assets]
summary: Clear preview asset
description: Clears the preview asset link for an existing asset.
x-feature-gate: enable-assets
parameters:
- name: id
in: path
description: The asset ID.
required: true
schema:
type: string
format: uuid
responses:
"204":
description: Preview cleared
"404":
description: Asset not found
/api/assets/{id}/tags:
post:
operationId: addAssetTags

View File

@ -0,0 +1,111 @@
import uuid
import pytest
import requests
def test_set_preview_success(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
"""PUT /api/assets/{id}/preview sets the preview and returns the full Asset."""
main_data = make_asset_bytes("main_asset.png", 2048)
preview_data = make_asset_bytes("preview_asset.png", 1024)
main = asset_factory("main_asset.png", ["input", "unit-tests"], {}, main_data)
preview = asset_factory("preview_asset.png", ["input", "unit-tests"], {}, preview_data)
r = http.put(
f"{api_base}/api/assets/{main['id']}/preview",
json={"preview_id": preview["id"]},
timeout=120,
)
body = r.json()
assert r.status_code == 200, body
assert body["id"] == main["id"]
assert body["preview_id"] == preview["id"]
def test_clear_preview_success(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
"""DELETE /api/assets/{id}/preview clears the preview and returns 204."""
main_data = make_asset_bytes("main_clear.png", 2048)
preview_data = make_asset_bytes("prev_clear.png", 1024)
main = asset_factory("main_clear.png", ["input", "unit-tests"], {}, main_data)
preview = asset_factory("prev_clear.png", ["input", "unit-tests"], {}, preview_data)
# First set a preview
r_set = http.put(
f"{api_base}/api/assets/{main['id']}/preview",
json={"preview_id": preview["id"]},
timeout=120,
)
assert r_set.status_code == 200
# Now clear it
r_del = http.delete(f"{api_base}/api/assets/{main['id']}/preview", timeout=120)
assert r_del.status_code == 204
# Verify preview_id is cleared
r_get = http.get(f"{api_base}/api/assets/{main['id']}", timeout=120)
detail = r_get.json()
assert r_get.status_code == 200, detail
assert detail.get("preview_id") is None
def test_set_preview_asset_not_found(http: requests.Session, api_base: str):
"""PUT /api/assets/{id}/preview returns 404 for non-existent asset ID."""
fake_id = str(uuid.uuid4())
r = http.put(
f"{api_base}/api/assets/{fake_id}/preview",
json={"preview_id": str(uuid.uuid4())},
timeout=120,
)
body = r.json()
assert r.status_code == 404
assert body["error"]["code"] == "ASSET_NOT_FOUND"
def test_clear_preview_asset_not_found(http: requests.Session, api_base: str):
"""DELETE /api/assets/{id}/preview returns 404 for non-existent asset ID."""
fake_id = str(uuid.uuid4())
r = http.delete(f"{api_base}/api/assets/{fake_id}/preview", timeout=120)
body = r.json()
assert r.status_code == 404
assert body["error"]["code"] == "ASSET_NOT_FOUND"
def test_set_preview_missing_body(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
"""PUT /api/assets/{id}/preview returns 400 when preview_id is not provided."""
main_data = make_asset_bytes("main_nobody.png", 2048)
main = asset_factory("main_nobody.png", ["input", "unit-tests"], {}, main_data)
r = http.put(
f"{api_base}/api/assets/{main['id']}/preview",
json={},
timeout=120,
)
body = r.json()
assert r.status_code == 400
assert body["error"]["code"] == "INVALID_BODY"
def test_set_preview_invalid_preview_id(
http: requests.Session, api_base: str, asset_factory, make_asset_bytes
):
"""PUT /api/assets/{id}/preview returns 404 when preview_id references non-existent asset."""
main_data = make_asset_bytes("main_badprev.png", 2048)
main = asset_factory("main_badprev.png", ["input", "unit-tests"], {}, main_data)
fake_preview_id = str(uuid.uuid4())
r = http.put(
f"{api_base}/api/assets/{main['id']}/preview",
json={"preview_id": fake_preview_id},
timeout=120,
)
body = r.json()
assert r.status_code == 404
assert body["error"]["code"] == "ASSET_NOT_FOUND"