basyx commited on
Commit
8da71d2
·
verified ·
1 Parent(s): 8f26f3d

Add tenant-scoped workspace asset library CRUD

Browse files
Files changed (1) hide show
  1. app/api/assets.py +191 -0
app/api/assets.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from uuid import UUID
9
+
10
+ from fastapi import APIRouter, HTTPException, Query, Request, Response, status
11
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
12
+ from sqlalchemy import func, or_, select
13
+ from sqlalchemy.exc import IntegrityError
14
+
15
+ from app.core.exceptions import NotFoundError
16
+ from app.generation.models import GenerationJob, GenerationRequest
17
+ from app.projects.models import Project, ProjectRenderJob
18
+ from app.security.assets import CanonicalAssetNotFoundError
19
+ from app.security.models import CanonicalMediaAsset, CanonicalMediaVariant
20
+
21
+ router = APIRouter(prefix="/v1/assets", tags=["assets"])
22
+
23
+
24
+ class AssetMetadataUpdate(BaseModel):
25
+ model_config = ConfigDict(extra="forbid")
26
+ metadata: dict[str, Any] = Field(default_factory=dict)
27
+
28
+ @model_validator(mode="after")
29
+ def metadata_is_bounded_json(self) -> "AssetMetadataUpdate":
30
+ try:
31
+ encoded = json.dumps(self.metadata, ensure_ascii=False, allow_nan=False)
32
+ except (TypeError, ValueError) as exc:
33
+ raise ValueError("metadata must contain JSON-compatible values") from exc
34
+ if len(encoded.encode("utf-8")) > 16_384:
35
+ raise ValueError("metadata must not exceed 16 KiB")
36
+ return self
37
+
38
+
39
+ def _identity(request: Request) -> tuple[str, str]:
40
+ context = getattr(request.state, "auth", None)
41
+ if context is None or not context.workspace_id or not context.user_id:
42
+ raise HTTPException(status_code=403, detail="An active workspace membership is required.")
43
+ return context.workspace_id, context.user_id
44
+
45
+
46
+ def _view(record: CanonicalMediaAsset, output_dir: Path) -> dict[str, Any]:
47
+ root = (output_dir / record.request_id).resolve()
48
+ path = (root / record.filename).resolve()
49
+ safe = path.parent == root
50
+ available = safe and path.is_file() and path.stat().st_size == record.file_size
51
+ return {
52
+ "id": record.id,
53
+ "workspace_id": record.workspace_id,
54
+ "project_id": record.project_id,
55
+ "request_id": record.request_id,
56
+ "filename": record.filename,
57
+ "mime_type": record.mime_type,
58
+ "file_size": record.file_size,
59
+ "metadata": record.metadata_json or {},
60
+ "file_available": available,
61
+ "created_at": record.created_at,
62
+ "updated_at": record.updated_at,
63
+ }
64
+
65
+
66
+ @router.get("", response_model=dict[str, Any])
67
+ async def list_assets(
68
+ request: Request,
69
+ project_id: UUID | None = Query(default=None),
70
+ q: str | None = Query(default=None, min_length=1, max_length=200),
71
+ offset: int = Query(default=0, ge=0),
72
+ limit: int = Query(default=100, ge=1, le=500),
73
+ ) -> dict[str, Any]:
74
+ workspace_id, user_id = _identity(request)
75
+ container = request.app.state.container
76
+ filters = [CanonicalMediaAsset.workspace_id == workspace_id]
77
+ if project_id is not None:
78
+ filters.append(CanonicalMediaAsset.project_id == str(project_id))
79
+ if q:
80
+ term = "%" + q.strip().replace("%", "\\%").replace("_", "\\_") + "%"
81
+ filters.append(or_(
82
+ CanonicalMediaAsset.filename.ilike(term, escape="\\"),
83
+ CanonicalMediaAsset.mime_type.ilike(term, escape="\\"),
84
+ ))
85
+ async with container.security_database.tenant_session(
86
+ workspace_id=workspace_id, user_id=user_id
87
+ ) as session:
88
+ total = await session.scalar(
89
+ select(func.count()).select_from(CanonicalMediaAsset).where(*filters)
90
+ )
91
+ records = (await session.scalars(
92
+ select(CanonicalMediaAsset)
93
+ .where(*filters)
94
+ .order_by(CanonicalMediaAsset.created_at.desc(), CanonicalMediaAsset.id)
95
+ .offset(offset)
96
+ .limit(limit)
97
+ )).all()
98
+ return {
99
+ "items": [_view(record, container.settings.output_dir) for record in records],
100
+ "total": int(total or 0),
101
+ "offset": offset,
102
+ "limit": limit,
103
+ }
104
+
105
+
106
+ @router.get("/{asset_id}", response_model=dict[str, Any])
107
+ async def get_asset(request: Request, asset_id: UUID) -> dict[str, Any]:
108
+ workspace_id, user_id = _identity(request)
109
+ container = request.app.state.container
110
+ try:
111
+ record = await container.assets.get_owned_by_id(
112
+ workspace_id=workspace_id, user_id=user_id, asset_id=str(asset_id)
113
+ )
114
+ path = container.cleanup.resolve_download(record.request_id, record.filename)
115
+ await container.assets.verify_file(record, path)
116
+ except (CanonicalAssetNotFoundError, NotFoundError) as exc:
117
+ raise HTTPException(status_code=404, detail="Asset was not found or failed integrity verification.") from exc
118
+ return _view(record, container.settings.output_dir)
119
+
120
+
121
+ @router.patch("/{asset_id}", response_model=dict[str, Any])
122
+ async def update_asset_metadata(
123
+ request: Request, asset_id: UUID, payload: AssetMetadataUpdate
124
+ ) -> dict[str, Any]:
125
+ workspace_id, user_id = _identity(request)
126
+ container = request.app.state.container
127
+ async with container.security_database.tenant_session(
128
+ workspace_id=workspace_id, user_id=user_id
129
+ ) as session:
130
+ record = await session.scalar(select(CanonicalMediaAsset).where(
131
+ CanonicalMediaAsset.id == str(asset_id),
132
+ CanonicalMediaAsset.workspace_id == workspace_id,
133
+ ))
134
+ if record is None:
135
+ raise HTTPException(status_code=404, detail="Asset was not found in this workspace.")
136
+ record.metadata_json = payload.metadata
137
+ record.updated_at = datetime.now(timezone.utc)
138
+ await session.commit()
139
+ await session.refresh(record)
140
+ return _view(record, container.settings.output_dir)
141
+
142
+
143
+ @router.delete("/{asset_id}", status_code=status.HTTP_204_NO_CONTENT)
144
+ async def delete_asset(request: Request, asset_id: UUID) -> Response:
145
+ workspace_id, user_id = _identity(request)
146
+ container = request.app.state.container
147
+ file_path: Path | None = None
148
+ async with container.security_database.tenant_session(
149
+ workspace_id=workspace_id, user_id=user_id
150
+ ) as session:
151
+ record = await session.scalar(
152
+ select(CanonicalMediaAsset)
153
+ .where(
154
+ CanonicalMediaAsset.id == str(asset_id),
155
+ CanonicalMediaAsset.workspace_id == workspace_id,
156
+ )
157
+ .with_for_update()
158
+ )
159
+ if record is None:
160
+ raise HTTPException(status_code=404, detail="Asset was not found in this workspace.")
161
+ if record.project_id is not None:
162
+ raise HTTPException(status_code=409, detail="Detach this asset from its project before deleting it.")
163
+ references = (
164
+ select(GenerationRequest.id).where(GenerationRequest.input_asset_id == record.id).limit(1),
165
+ select(GenerationJob.id).where(GenerationJob.output_asset_id == record.id).limit(1),
166
+ select(Project.thumbnail_asset_id).where(Project.thumbnail_asset_id == record.id).limit(1),
167
+ select(ProjectRenderJob.id).where(ProjectRenderJob.output_asset_id == record.id).limit(1),
168
+ select(CanonicalMediaVariant.id).where(CanonicalMediaVariant.asset_id == record.id).limit(1),
169
+ )
170
+ for statement in references:
171
+ if await session.scalar(statement) is not None:
172
+ raise HTTPException(status_code=409, detail="This asset is referenced by a project or job and cannot be deleted.")
173
+ try:
174
+ file_path = container.cleanup.resolve_download(record.request_id, record.filename)
175
+ await container.assets.verify_file(record, file_path)
176
+ except (CanonicalAssetNotFoundError, NotFoundError):
177
+ file_path = None
178
+ try:
179
+ await session.delete(record)
180
+ await session.commit()
181
+ except IntegrityError as exc:
182
+ await session.rollback()
183
+ raise HTTPException(status_code=409, detail="This asset is still referenced and cannot be deleted.") from exc
184
+ if file_path is not None:
185
+ try:
186
+ await asyncio.to_thread(file_path.unlink, missing_ok=True)
187
+ except OSError:
188
+ # The database record is already gone; the periodic output cleanup
189
+ # reconciles any orphaned file without exposing its filesystem path.
190
+ pass
191
+ return Response(status_code=status.HTTP_204_NO_CONTENT)