diff --git a/alembic_db/versions/0001_assets.py b/alembic_db/versions/0001_assets.py index 8499306ba..c80874aa2 100644 --- a/alembic_db/versions/0001_assets.py +++ b/alembic_db/versions/0001_assets.py @@ -30,7 +30,7 @@ def upgrade() -> None: # ASSETS_INFO: user-visible references (mutable metadata) op.create_table( "assets_info", - sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("id", sa.String(length=36), primary_key=True), sa.Column("owner_id", sa.String(length=128), nullable=False, server_default=""), sa.Column("name", sa.String(length=512), nullable=False), sa.Column("asset_hash", sa.String(length=256), sa.ForeignKey("assets.hash", ondelete="RESTRICT"), nullable=False), @@ -40,7 +40,6 @@ def upgrade() -> None: sa.Column("updated_at", sa.DateTime(timezone=False), nullable=False), sa.Column("last_access_time", sa.DateTime(timezone=False), nullable=False), sa.UniqueConstraint("asset_hash", "owner_id", "name", name="uq_assets_info_hash_owner_name"), - sqlite_autoincrement=True, ) op.create_index("ix_assets_info_owner_id", "assets_info", ["owner_id"]) op.create_index("ix_assets_info_asset_hash", "assets_info", ["asset_hash"]) @@ -61,7 +60,7 @@ def upgrade() -> None: # ASSET_INFO_TAGS: many-to-many for tags on AssetInfo op.create_table( "asset_info_tags", - sa.Column("asset_info_id", sa.Integer(), sa.ForeignKey("assets_info.id", ondelete="CASCADE"), nullable=False), + sa.Column("asset_info_id", sa.String(length=36), sa.ForeignKey("assets_info.id", ondelete="CASCADE"), nullable=False), sa.Column("tag_name", sa.String(length=512), sa.ForeignKey("tags.name", ondelete="RESTRICT"), nullable=False), sa.Column("origin", sa.String(length=32), nullable=False, server_default="manual"), sa.Column("added_at", sa.DateTime(timezone=False), nullable=False), @@ -83,7 +82,7 @@ def upgrade() -> None: # ASSET_INFO_META: typed KV projection of user_metadata for filtering/sorting op.create_table( "asset_info_meta", - sa.Column("asset_info_id", sa.Integer(), sa.ForeignKey("assets_info.id", ondelete="CASCADE"), nullable=False), + sa.Column("asset_info_id", sa.String(length=36), sa.ForeignKey("assets_info.id", ondelete="CASCADE"), nullable=False), sa.Column("key", sa.String(length=256), nullable=False), sa.Column("ordinal", sa.Integer(), nullable=False, server_default="0"), sa.Column("val_str", sa.String(length=2048), nullable=True), diff --git a/app/api/assets_routes.py b/app/api/assets_routes.py index 61188c090..71e99f231 100644 --- a/app/api/assets_routes.py +++ b/app/api/assets_routes.py @@ -16,6 +16,9 @@ from . import schemas_in, schemas_out ROUTES = web.RouteTableDef() UserManager: Optional[user_manager.UserManager] = None +# UUID regex (canonical hyphenated form, case-insensitive) +UUID_RE = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + @ROUTES.head("/api/assets/hash/{hash}") async def head_asset_by_hash(request: web.Request) -> web.Response: @@ -52,13 +55,13 @@ async def list_assets(request: web.Request) -> web.Response: return web.json_response(payload.model_dump(mode="json")) -@ROUTES.get("/api/assets/{id:\\d+}/content") +@ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}/content") async def download_asset_content(request: web.Request) -> web.Response: - asset_info_id_raw = request.match_info.get("id") + asset_info_id_raw = request.match_info.get("id", "") try: - asset_info_id = int(asset_info_id_raw) + asset_info_id = str(uuid.UUID(asset_info_id_raw)) except Exception: - return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid integer.") + return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid UUID.") disposition = request.query.get("disposition", "attachment").lower().strip() if disposition not in {"inline", "attachment"}: @@ -282,13 +285,13 @@ async def upload_asset(request: web.Request) -> web.Response: return _error_response(500, "INTERNAL", "Unexpected server error.") -@ROUTES.get("/api/assets/{id:\\d+}") +@ROUTES.get(f"/api/assets/{{id:{UUID_RE}}}") async def get_asset(request: web.Request) -> web.Response: - asset_info_id_raw = request.match_info.get("id") + asset_info_id_raw = request.match_info.get("id", "") try: - asset_info_id = int(asset_info_id_raw) + asset_info_id = str(uuid.UUID(asset_info_id_raw)) except Exception: - return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid integer.") + return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid UUID.") try: result = await assets_manager.get_asset( @@ -302,13 +305,13 @@ async def get_asset(request: web.Request) -> web.Response: return web.json_response(result.model_dump(mode="json"), status=200) -@ROUTES.put("/api/assets/{id:\\d+}") +@ROUTES.put(f"/api/assets/{{id:{UUID_RE}}}") async def update_asset(request: web.Request) -> web.Response: - asset_info_id_raw = request.match_info.get("id") + asset_info_id_raw = request.match_info.get("id", "") try: - asset_info_id = int(asset_info_id_raw) + asset_info_id = str(uuid.UUID(asset_info_id_raw)) except Exception: - return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid integer.") + return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid UUID.") try: body = schemas_in.UpdateAssetBody.model_validate(await request.json()) @@ -332,13 +335,13 @@ 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:\\d+}") +@ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}") async def delete_asset(request: web.Request) -> web.Response: - asset_info_id_raw = request.match_info.get("id") + asset_info_id_raw = request.match_info.get("id", "") try: - asset_info_id = int(asset_info_id_raw) + asset_info_id = str(uuid.UUID(asset_info_id_raw)) except Exception: - return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid integer.") + return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid UUID.") try: deleted = await assets_manager.delete_asset_reference( @@ -376,13 +379,13 @@ async def get_tags(request: web.Request) -> web.Response: return web.json_response(result.model_dump(mode="json")) -@ROUTES.post("/api/assets/{id:\\d+}/tags") +@ROUTES.post(f"/api/assets/{{id:{UUID_RE}}}/tags") async def add_asset_tags(request: web.Request) -> web.Response: - asset_info_id_raw = request.match_info.get("id") + asset_info_id_raw = request.match_info.get("id", "") try: - asset_info_id = int(asset_info_id_raw) + asset_info_id = str(uuid.UUID(asset_info_id_raw)) except Exception: - return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid integer.") + return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid UUID.") try: payload = await request.json() @@ -407,13 +410,13 @@ 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:\\d+}/tags") +@ROUTES.delete(f"/api/assets/{{id:{UUID_RE}}}/tags") async def delete_asset_tags(request: web.Request) -> web.Response: - asset_info_id_raw = request.match_info.get("id") + asset_info_id_raw = request.match_info.get("id", "") try: - asset_info_id = int(asset_info_id_raw) + asset_info_id = str(uuid.UUID(asset_info_id_raw)) except Exception: - return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid integer.") + return _error_response(400, "INVALID_ID", f"AssetInfo id '{asset_info_id_raw}' is not a valid UUID.") try: payload = await request.json() diff --git a/app/api/schemas_out.py b/app/api/schemas_out.py index 1b41d8021..581d71796 100644 --- a/app/api/schemas_out.py +++ b/app/api/schemas_out.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_serializer class AssetSummary(BaseModel): - id: int + id: str name: str asset_hash: str size: Optional[int] = None @@ -29,7 +29,7 @@ class AssetsList(BaseModel): class AssetUpdated(BaseModel): - id: int + id: str name: str asset_hash: str tags: list[str] = Field(default_factory=list) @@ -44,7 +44,7 @@ class AssetUpdated(BaseModel): class AssetDetail(BaseModel): - id: int + id: str name: str asset_hash: str size: Optional[int] = None diff --git a/app/assets_manager.py b/app/assets_manager.py index f9046c5ed..3d7c040c4 100644 --- a/app/assets_manager.py +++ b/app/assets_manager.py @@ -73,7 +73,7 @@ async def add_local_asset(tags: list[str], file_name: str, file_path: str) -> No async with await create_session() as session: if await check_fs_asset_exists_quick(session, file_path=abs_path, size_bytes=size_bytes, mtime_ns=mtime_ns): - await touch_asset_infos_by_fs_path(session, abs_path=abs_path) + await touch_asset_infos_by_fs_path(session, file_path=abs_path) await session.commit() return @@ -149,7 +149,7 @@ async def list_assets( ) -async def get_asset(*, asset_info_id: int, owner_id: str = "") -> schemas_out.AssetDetail: +async def get_asset(*, asset_info_id: str, owner_id: str = "") -> 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, owner_id=owner_id) if not res: @@ -172,7 +172,7 @@ async def get_asset(*, asset_info_id: int, owner_id: str = "") -> schemas_out.As async def resolve_asset_content_for_download( *, - asset_info_id: int, + asset_info_id: str, owner_id: str = "", ) -> tuple[str, str, str]: """ @@ -311,7 +311,7 @@ async def upload_asset_from_temp_path( if not info_id: raise RuntimeError("failed to create asset metadata") - pair = await fetch_asset_info_and_asset(session, asset_info_id=int(info_id), owner_id=owner_id) + pair = await fetch_asset_info_and_asset(session, asset_info_id=info_id, owner_id=owner_id) if not pair: raise RuntimeError("inconsistent DB state after ingest") info, asset = pair @@ -335,7 +335,7 @@ async def upload_asset_from_temp_path( async def update_asset( *, - asset_info_id: int, + asset_info_id: str, name: Optional[str] = None, tags: Optional[list[str]] = None, user_metadata: Optional[dict] = None, @@ -371,7 +371,7 @@ async def update_asset( ) -async def delete_asset_reference(*, asset_info_id: int, owner_id: str) -> bool: +async def delete_asset_reference(*, asset_info_id: str, owner_id: str) -> bool: async with await create_session() as session: r = await delete_asset_info_by_id(session, asset_info_id=asset_info_id, owner_id=owner_id) await session.commit() @@ -448,7 +448,7 @@ async def list_tags( async def add_tags_to_asset( *, - asset_info_id: int, + asset_info_id: str, tags: list[str], origin: str = "manual", owner_id: str = "", @@ -473,7 +473,7 @@ async def add_tags_to_asset( async def remove_tags_from_asset( *, - asset_info_id: int, + asset_info_id: str, tags: list[str], owner_id: str = "", ) -> schemas_out.TagsRemove: diff --git a/app/database/models.py b/app/database/models.py index ea3f4970d..47f8bbaf3 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,5 +1,6 @@ from datetime import datetime from typing import Any, Optional +import uuid from sqlalchemy import ( Integer, @@ -135,7 +136,7 @@ class AssetLocation(Base): class AssetInfo(Base): __tablename__ = "assets_info" - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) owner_id: Mapped[str] = mapped_column(String(128), nullable=False, default="") name: Mapped[str] = mapped_column(String(512), nullable=False) asset_hash: Mapped[str] = mapped_column( @@ -194,7 +195,6 @@ class AssetInfo(Base): Index("ix_assets_info_name", "name"), Index("ix_assets_info_created_at", "created_at"), Index("ix_assets_info_last_access_time", "last_access_time"), - {"sqlite_autoincrement": True}, ) def to_dict(self, include_none: bool = False) -> dict[str, Any]: @@ -209,8 +209,8 @@ class AssetInfo(Base): class AssetInfoMeta(Base): __tablename__ = "asset_info_meta" - asset_info_id: Mapped[int] = mapped_column( - Integer, ForeignKey("assets_info.id", ondelete="CASCADE"), primary_key=True + asset_info_id: Mapped[str] = mapped_column( + String(36), ForeignKey("assets_info.id", ondelete="CASCADE"), primary_key=True ) key: Mapped[str] = mapped_column(String(256), primary_key=True) ordinal: Mapped[int] = mapped_column(Integer, primary_key=True, default=0) @@ -233,8 +233,8 @@ class AssetInfoMeta(Base): class AssetInfoTag(Base): __tablename__ = "asset_info_tags" - asset_info_id: Mapped[int] = mapped_column( - Integer, ForeignKey("assets_info.id", ondelete="CASCADE"), primary_key=True + asset_info_id: Mapped[str] = mapped_column( + String(36), ForeignKey("assets_info.id", ondelete="CASCADE"), primary_key=True ) tag_name: Mapped[str] = mapped_column( String(512), ForeignKey("tags.name", ondelete="RESTRICT"), primary_key=True diff --git a/app/database/services.py b/app/database/services.py index d1b85e160..da8c02f67 100644 --- a/app/database/services.py +++ b/app/database/services.py @@ -30,7 +30,7 @@ async def get_asset_by_hash(session: AsyncSession, *, asset_hash: str) -> Option return await session.get(Asset, asset_hash) -async def get_asset_info_by_id(session: AsyncSession, *, asset_info_id: int) -> Optional[AssetInfo]: +async def get_asset_info_by_id(session: AsyncSession, *, asset_info_id: str) -> Optional[AssetInfo]: return await session.get(AssetInfo, asset_info_id) @@ -100,13 +100,13 @@ async def ingest_fs_asset( "asset_updated": bool, "state_created": bool, "state_updated": bool, - "asset_info_id": int | None, + "asset_info_id": str | None, } """ locator = os.path.abspath(abs_path) datetime_now = utcnow() - out = { + out: dict[str, Any] = { "asset_created": False, "asset_updated": False, "state_created": False, @@ -187,7 +187,7 @@ async def ingest_fs_asset( last_access_time=datetime_now, ) session.add(info) - await session.flush() # get info.id + await session.flush() # get info.id (UUID) out["asset_info_id"] = info.id existing_info = ( @@ -263,11 +263,11 @@ async def ingest_fs_asset( async def touch_asset_infos_by_fs_path( session: AsyncSession, *, - abs_path: str, + file_path: str, ts: Optional[datetime] = None, only_if_newer: bool = True, ) -> int: - locator = os.path.abspath(abs_path) + locator = os.path.abspath(file_path) ts = ts or utcnow() stmt = sa.update(AssetInfo).where( @@ -298,7 +298,7 @@ async def touch_asset_infos_by_fs_path( async def touch_asset_info_by_id( session: AsyncSession, *, - asset_info_id: int, + asset_info_id: str, ts: Optional[datetime] = None, only_if_newer: bool = True, ) -> int: @@ -325,7 +325,7 @@ async def list_asset_infos_page( offset: int = 0, sort: str = "created_at", order: str = "desc", -) -> tuple[list[AssetInfo], dict[int, list[str]], int]: +) -> tuple[list[AssetInfo], dict[str, list[str]], int]: """Return page of AssetInfo rows in the viewers visibility.""" base = ( select(AssetInfo) @@ -373,8 +373,8 @@ async def list_asset_infos_page( infos = (await session.execute(base)).scalars().unique().all() # Collect tags in bulk (single query) - id_list = [i.id for i in infos] - tag_map: dict[int, list[str]] = defaultdict(list) + id_list: list[str] = [i.id for i in infos] + tag_map: dict[str, list[str]] = defaultdict(list) if id_list: rows = await session.execute( select(AssetInfoTag.asset_info_id, Tag.name) @@ -390,7 +390,7 @@ async def list_asset_infos_page( async def fetch_asset_info_and_asset( session: AsyncSession, *, - asset_info_id: int, + asset_info_id: str, owner_id: str = "", ) -> Optional[tuple[AssetInfo, Asset]]: stmt = ( @@ -412,7 +412,7 @@ async def fetch_asset_info_and_asset( async def fetch_asset_info_asset_and_tags( session: AsyncSession, *, - asset_info_id: int, + asset_info_id: str, owner_id: str = "", ) -> Optional[tuple[AssetInfo, Asset, list[str]]]: stmt = ( @@ -449,7 +449,7 @@ async def get_cache_state_by_asset_hash(session: AsyncSession, *, asset_hash: st async def list_asset_locations( session: AsyncSession, *, asset_hash: str, provider: Optional[str] = None -) -> list[AssetLocation]: +) -> list[AssetLocation] | Sequence[AssetLocation]: stmt = select(AssetLocation).where(AssetLocation.asset_hash == asset_hash) if provider: stmt = stmt.where(AssetLocation.provider == provider) @@ -545,7 +545,7 @@ async def create_asset_info_for_existing_asset( async def set_asset_info_tags( session: AsyncSession, *, - asset_info_id: int, + asset_info_id: str, tags: Sequence[str], origin: str = "manual", ) -> dict: @@ -586,7 +586,7 @@ async def set_asset_info_tags( async def update_asset_info_full( session: AsyncSession, *, - asset_info_id: int, + asset_info_id: str, name: Optional[str] = None, tags: Optional[Sequence[str]] = None, user_metadata: Optional[dict] = None, @@ -634,7 +634,7 @@ async def update_asset_info_full( return info -async def delete_asset_info_by_id(session: AsyncSession, *, asset_info_id: int, owner_id: str) -> bool: +async def delete_asset_info_by_id(session: AsyncSession, *, asset_info_id: str, owner_id: str) -> bool: """Delete the user-visible AssetInfo row. Cascades clear tags and metadata.""" res = await session.execute(delete(AssetInfo).where( AssetInfo.id == asset_info_id, @@ -646,7 +646,7 @@ async def delete_asset_info_by_id(session: AsyncSession, *, asset_info_id: int, async def replace_asset_info_metadata_projection( session: AsyncSession, *, - asset_info_id: int, + asset_info_id: str, user_metadata: Optional[dict], ) -> None: """Replaces the `assets_info.user_metadata` AND rebuild the projection rows in `asset_info_meta`.""" @@ -683,7 +683,7 @@ async def replace_asset_info_metadata_projection( await session.flush() -async def get_asset_tags(session: AsyncSession, *, asset_info_id: int) -> list[str]: +async def get_asset_tags(session: AsyncSession, *, asset_info_id: str) -> list[str]: return [ tag_name for (tag_name,) in ( @@ -763,7 +763,7 @@ async def list_tags_with_usage( async def add_tags_to_asset_info( session: AsyncSession, *, - asset_info_id: int, + asset_info_id: str, tags: Sequence[str], origin: str = "manual", create_if_missing: bool = True, @@ -829,7 +829,7 @@ async def add_tags_to_asset_info( async def remove_tags_from_asset_info( session: AsyncSession, *, - asset_info_id: int, + asset_info_id: str, tags: Sequence[str], ) -> dict: """Removes tags from an AssetInfo.