From 9e3f559189dd2338757194ca47afc2403dc88bb9 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Fri, 16 Jan 2026 00:45:36 -0800 Subject: [PATCH] Finished @ROUTES.post(f"/api/assets/{{id:{UUID_RE}}}/tags") --- app/assets/api/routes.py | 82 +++++++++++++++++++++++----------- app/assets/api/schemas_in.py | 23 ++++++++++ app/assets/api/schemas_out.py | 7 +++ app/assets/database/queries.py | 60 +++++++++++++++++++++++++ app/assets/manager.py | 26 +++++++++++ 5 files changed, 172 insertions(+), 26 deletions(-) diff --git a/app/assets/api/routes.py b/app/assets/api/routes.py index f176b4f01..20e796b4d 100644 --- a/app/assets/api/routes.py +++ b/app/assets/api/routes.py @@ -93,32 +93,6 @@ async def get_asset(request: web.Request) -> web.Response: return web.json_response(result.model_dump(mode="json"), status=200) -@ROUTES.get("/api/tags") -async def get_tags(request: web.Request) -> web.Response: - """ - GET request to list all tags based on query parameters. - """ - query_map = dict(request.rel_url.query) - - try: - query = schemas_in.TagsListQuery.model_validate(query_map) - except ValidationError as e: - return web.json_response( - {"error": {"code": "INVALID_QUERY", "message": "Invalid query parameters", "details": e.errors()}}, - status=400, - ) - - result = manager.list_tags( - prefix=query.prefix, - limit=query.limit, - offset=query.offset, - order=query.order, - include_zero=query.include_zero, - owner_id=USER_MANAGER.get_request_user_id(request), - ) - return web.json_response(result.model_dump(mode="json")) - - @ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}/content") async def download_asset_content(request: web.Request) -> web.Response: # question: do we need disposition? could we just stick with one of these? @@ -432,3 +406,59 @@ async def delete_asset(request: web.Request) -> web.Response: if not deleted: return _error_response(404, "ASSET_NOT_FOUND", f"AssetInfo {asset_info_id} not found.") return web.Response(status=204) + + +@ROUTES.get("/api/tags") +async def get_tags(request: web.Request) -> web.Response: + """ + GET request to list all tags based on query parameters. + """ + query_map = dict(request.rel_url.query) + + try: + query = schemas_in.TagsListQuery.model_validate(query_map) + except ValidationError as e: + return web.json_response( + {"error": {"code": "INVALID_QUERY", "message": "Invalid query parameters", "details": e.errors()}}, + status=400, + ) + + result = manager.list_tags( + prefix=query.prefix, + limit=query.limit, + offset=query.offset, + order=query.order, + include_zero=query.include_zero, + owner_id=USER_MANAGER.get_request_user_id(request), + ) + return web.json_response(result.model_dump(mode="json")) + +@ROUTES.post(f"/api/assets/{{id:{UUID_RE}}}/tags") +async def add_asset_tags(request: web.Request) -> web.Response: + asset_info_id = str(uuid.UUID(request.match_info["id"])) + try: + payload = await request.json() + data = schemas_in.TagsAdd.model_validate(payload) + except ValidationError as ve: + return _error_response(400, "INVALID_BODY", "Invalid JSON body for tags add.", {"errors": ve.errors()}) + except Exception: + return _error_response(400, "INVALID_JSON", "Request body must be valid JSON.") + + try: + result = manager.add_tags_to_asset( + asset_info_id=asset_info_id, + tags=data.tags, + origin="manual", + owner_id=USER_MANAGER.get_request_user_id(request), + ) + except (ValueError, PermissionError) as ve: + return _error_response(404, "ASSET_NOT_FOUND", str(ve), {"id": asset_info_id}) + except Exception: + logging.exception( + "add_tags_to_asset failed for asset_info_id=%s, owner_id=%s", + asset_info_id, + USER_MANAGER.get_request_user_id(request), + ) + return _error_response(500, "INTERNAL", "Unexpected server error.") + + return web.json_response(result.model_dump(mode="json"), status=200) diff --git a/app/assets/api/schemas_in.py b/app/assets/api/schemas_in.py index 99e7ef41b..02908120d 100644 --- a/app/assets/api/schemas_in.py +++ b/app/assets/api/schemas_in.py @@ -131,6 +131,29 @@ class TagsListQuery(BaseModel): return v.lower() or None +class TagsAdd(BaseModel): + model_config = ConfigDict(extra="ignore") + tags: list[str] = Field(..., min_length=1) + + @field_validator("tags") + @classmethod + def normalize_tags(cls, v: list[str]) -> list[str]: + out = [] + for t in v: + if not isinstance(t, str): + raise TypeError("tags must be strings") + tnorm = t.strip().lower() + if tnorm: + out.append(tnorm) + seen = set() + deduplicated = [] + for x in out: + if x not in seen: + seen.add(x) + deduplicated.append(x) + return deduplicated + + class UploadAssetSpec(BaseModel): """Upload Asset operation. - tags: ordered; first is root ('models'|'input'|'output'); diff --git a/app/assets/api/schemas_out.py b/app/assets/api/schemas_out.py index d226b4b43..3c1127fae 100644 --- a/app/assets/api/schemas_out.py +++ b/app/assets/api/schemas_out.py @@ -77,3 +77,10 @@ class TagsList(BaseModel): tags: list[TagUsage] = Field(default_factory=list) total: int has_more: bool + + +class TagsAdd(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + added: list[str] = Field(default_factory=list) + already_present: list[str] = Field(default_factory=list) + total_tags: list[str] = Field(default_factory=list) diff --git a/app/assets/database/queries.py b/app/assets/database/queries.py index 1d50c6054..d51a02fe2 100644 --- a/app/assets/database/queries.py +++ b/app/assets/database/queries.py @@ -838,6 +838,66 @@ def get_asset_tags(session: Session, *, asset_info_id: str) -> list[str]: ] +def add_tags_to_asset_info( + session: Session, + *, + asset_info_id: str, + tags: Sequence[str], + origin: str = "manual", + create_if_missing: bool = True, + asset_info_row: Any = None, +) -> dict: + if not asset_info_row: + info = session.get(AssetInfo, asset_info_id) + if not info: + raise ValueError(f"AssetInfo {asset_info_id} not found") + + norm = normalize_tags(tags) + if not norm: + total = get_asset_tags(session, asset_info_id=asset_info_id) + return {"added": [], "already_present": [], "total_tags": total} + + if create_if_missing: + ensure_tags_exist(session, norm, tag_type="user") + + current = { + tag_name + for (tag_name,) in ( + session.execute( + sa.select(AssetInfoTag.tag_name).where(AssetInfoTag.asset_info_id == asset_info_id) + ) + ).all() + } + + want = set(norm) + to_add = sorted(want - current) + + if to_add: + with session.begin_nested() as nested: + try: + session.add_all( + [ + AssetInfoTag( + asset_info_id=asset_info_id, + tag_name=t, + origin=origin, + added_at=utcnow(), + ) + for t in to_add + ] + ) + session.flush() + except IntegrityError: + nested.rollback() + + after = set(get_asset_tags(session, asset_info_id=asset_info_id)) + return { + "added": sorted(((after - current) & want)), + "already_present": sorted(want & current), + "total_tags": sorted(after), + } + + def remove_missing_tag_for_asset_id( session: Session, *, diff --git a/app/assets/manager.py b/app/assets/manager.py index bcda0df08..666f940bc 100644 --- a/app/assets/manager.py +++ b/app/assets/manager.py @@ -20,6 +20,7 @@ from app.assets.database.queries import ( list_asset_infos_page, list_tags_with_usage, get_asset_tags, + add_tags_to_asset_info, pick_best_live_path, ingest_fs_asset, set_asset_info_preview, @@ -434,6 +435,31 @@ def create_asset_from_hash( ) +def add_tags_to_asset( + *, + asset_info_id: str, + tags: list[str], + origin: str = "manual", + owner_id: str = "", +) -> schemas_out.TagsAdd: + with create_session() as session: + info_row = get_asset_info_by_id(session, asset_info_id=asset_info_id) + if not info_row: + raise ValueError(f"AssetInfo {asset_info_id} not found") + if info_row.owner_id and info_row.owner_id != owner_id: + raise PermissionError("not owner") + data = add_tags_to_asset_info( + session, + asset_info_id=asset_info_id, + tags=tags, + origin=origin, + create_if_missing=True, + asset_info_row=info_row, + ) + session.commit() + return schemas_out.TagsAdd(**data) + + def list_tags( prefix: str | None = None, limit: int = 100,