diff --git a/app/api/assets_routes.py b/app/api/assets_routes.py index c0dde7909..2ca2932e5 100644 --- a/app/api/assets_routes.py +++ b/app/api/assets_routes.py @@ -49,7 +49,7 @@ async def list_assets(request: web.Request) -> web.Response: return web.json_response(payload.model_dump(mode="json")) -@ROUTES.get("/api/assets/{id}/content") +@ROUTES.get("/api/assets/{id:\\d+}/content") async def download_asset_content(request: web.Request) -> web.Response: asset_info_id_raw = request.match_info.get("id") try: @@ -198,7 +198,24 @@ async def upload_asset(request: web.Request) -> web.Response: return _error_response(500, "INTERNAL", "Unexpected server error.") -@ROUTES.put("/api/assets/{id}") +@ROUTES.get("/api/assets/{id:\\d+}") +async def get_asset(request: web.Request) -> web.Response: + asset_info_id_raw = request.match_info.get("id") + try: + asset_info_id = int(asset_info_id_raw) + except Exception: + return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid integer.") + + try: + result = await assets_manager.get_asset(asset_info_id=asset_info_id) + except ValueError as ve: + return _error_response(404, "ASSET_NOT_FOUND", str(ve), {"id": asset_info_id}) + except Exception: + return _error_response(500, "INTERNAL", "Unexpected server error.") + return web.json_response(result.model_dump(mode="json"), status=200) + + +@ROUTES.put("/api/assets/{id:\\d+}") async def update_asset(request: web.Request) -> web.Response: asset_info_id_raw = request.match_info.get("id") try: @@ -227,7 +244,7 @@ async def update_asset(request: web.Request) -> web.Response: return web.json_response(result.model_dump(mode="json"), status=200) -@ROUTES.delete("/api/assets/{id}") +@ROUTES.delete("/api/assets/{id:\\d+}") async def delete_asset(request: web.Request) -> web.Response: asset_info_id_raw = request.match_info.get("id") try: @@ -267,7 +284,7 @@ async def get_tags(request: web.Request) -> web.Response: return web.json_response(result.model_dump(mode="json")) -@ROUTES.post("/api/assets/{id}/tags") +@ROUTES.post("/api/assets/{id:\\d+}/tags") async def add_asset_tags(request: web.Request) -> web.Response: asset_info_id_raw = request.match_info.get("id") try: @@ -298,7 +315,7 @@ async def add_asset_tags(request: web.Request) -> web.Response: return web.json_response(result.model_dump(mode="json"), status=200) -@ROUTES.delete("/api/assets/{id}/tags") +@ROUTES.delete("/api/assets/{id:\\d+}/tags") async def delete_asset_tags(request: web.Request) -> web.Response: asset_info_id_raw = request.match_info.get("id") try: diff --git a/app/api/schemas_out.py b/app/api/schemas_out.py index 8aca0ee01..1b41d8021 100644 --- a/app/api/schemas_out.py +++ b/app/api/schemas_out.py @@ -43,7 +43,7 @@ class AssetUpdated(BaseModel): return v.isoformat() if v else None -class AssetCreated(BaseModel): +class AssetDetail(BaseModel): id: int name: str asset_hash: str @@ -54,7 +54,6 @@ class AssetCreated(BaseModel): preview_hash: Optional[str] = None created_at: Optional[datetime] = None last_access_time: Optional[datetime] = None - created_new: bool model_config = ConfigDict(from_attributes=True) @@ -63,6 +62,10 @@ class AssetCreated(BaseModel): return v.isoformat() if v else None +class AssetCreated(AssetDetail): + created_new: bool + + class TagUsage(BaseModel): name: str count: int diff --git a/app/assets_manager.py b/app/assets_manager.py index 72d299467..e61895c8a 100644 --- a/app/assets_manager.py +++ b/app/assets_manager.py @@ -24,6 +24,7 @@ from .database.services import ( asset_exists_by_hash, get_asset_by_hash, create_asset_info_for_existing_asset, + fetch_asset_info_asset_and_tags, ) from .api import schemas_in, schemas_out from ._assets_helpers import get_name_and_tags_from_asset_path, ensure_within_base, resolve_destination_from_tags @@ -140,6 +141,27 @@ async def list_assets( ) +async def get_asset(*, asset_info_id: int) -> schemas_out.AssetDetail: + async with await create_session() as session: + res = await fetch_asset_info_asset_and_tags(session, asset_info_id=asset_info_id) + if not res: + raise ValueError(f"AssetInfo {asset_info_id} not found") + info, asset, tag_names = res + + return schemas_out.AssetDetail( + id=info.id, + name=info.name, + asset_hash=info.asset_hash, + size=int(asset.size_bytes) if asset and asset.size_bytes is not None else None, + mime_type=asset.mime_type if asset else None, + tags=tag_names, + preview_hash=info.preview_hash, + user_metadata=info.user_metadata or {}, + created_at=info.created_at, + last_access_time=info.last_access_time, + ) + + async def resolve_asset_content_for_download( *, asset_info_id: int ) -> tuple[str, str, str]: diff --git a/app/database/services.py b/app/database/services.py index 5f1ffffbf..95a2d07ab 100644 --- a/app/database/services.py +++ b/app/database/services.py @@ -9,7 +9,7 @@ from typing import Any, Sequence, Optional, Iterable import sqlalchemy as sa from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete, exists, func -from sqlalchemy.orm import contains_eager +from sqlalchemy.orm import contains_eager, noload from sqlalchemy.exc import IntegrityError from .models import Asset, AssetInfo, AssetInfoTag, AssetCacheState, Tag, AssetInfoMeta, AssetLocation @@ -407,6 +407,41 @@ async def fetch_asset_info_and_asset(session: AsyncSession, *, asset_info_id: in return pair[0], pair[1] +async def fetch_asset_info_asset_and_tags( + session: AsyncSession, + *, + asset_info_id: int, +) -> Optional[tuple[AssetInfo, Asset, list[str]]]: + """Fetch AssetInfo, its Asset, and all tag names. + + Returns: + (AssetInfo, Asset, [tag_names]) or None if the asset_info_id does not exist. + """ + stmt = ( + select(AssetInfo, Asset, Tag.name) + .join(Asset, Asset.hash == AssetInfo.asset_hash) + .join(AssetInfoTag, AssetInfoTag.asset_info_id == AssetInfo.id, isouter=True) + .join(Tag, Tag.name == AssetInfoTag.tag_name, isouter=True) + .where(AssetInfo.id == asset_info_id) + .options(noload(AssetInfo.tags)) + .order_by(Tag.name.asc()) + ) + + rows = (await session.execute(stmt)).all() + if not rows: + return None + + # First row contains the mapped entities; tags may repeat across rows + first_info, first_asset, _ = rows[0] + tags: list[str] = [] + seen: set[str] = set() + for _info, _asset, tag_name in rows: + if tag_name and tag_name not in seen: + seen.add(tag_name) + tags.append(tag_name) + return first_info, first_asset, tags + + async def get_cache_state_by_asset_hash(session: AsyncSession, *, asset_hash: str) -> Optional[AssetCacheState]: return await session.get(AssetCacheState, asset_hash)