feat(E): add MediaAsset catalog — model, CRUD API, MediaBrowser UI

Migration 040: media_assets table with RLS (tenant_isolation + admin_bypass).
domains/media/: MediaAsset model, schemas, service, router with ZIP-download.
publish_asset Celery task in rendering/tasks.py.
core/storage.py: download_bytes() method for MinIO + LocalStorage.
frontend: MediaBrowser.tsx (grid/list, multi-select, zip-download, pagination) + api/media.ts.
Route /media (AdminRoute) + sidebar link with Image icon for admin+pm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 17:11:17 +01:00
parent 716451ff76
commit c74e118b98
14 changed files with 870 additions and 2 deletions
+69
View File
@@ -0,0 +1,69 @@
import api from './client'
export type MediaAssetType =
| 'thumbnail'
| 'still'
| 'turntable'
| 'stl_low'
| 'stl_high'
| 'gltf_geometry'
| 'gltf_production'
| 'blend_production'
export interface MediaAsset {
id: string
tenant_id: string | null
product_id: string | null
cad_file_id: string | null
order_line_id: string | null
workflow_run_id: string | null
asset_type: MediaAssetType
storage_key: string
file_size_bytes: number | null
mime_type: string | null
width: number | null
height: number | null
duration_s: number | null
render_config: Record<string, unknown> | null
is_archived: boolean
created_at: string
download_url: string | null
}
export interface MediaFilter {
product_id?: string
order_line_id?: string
asset_type?: MediaAssetType
skip?: number
limit?: number
}
export const getMediaAssets = (filters: MediaFilter = {}): Promise<MediaAsset[]> => {
const params = new URLSearchParams()
if (filters.product_id) params.set('product_id', filters.product_id)
if (filters.order_line_id) params.set('order_line_id', filters.order_line_id)
if (filters.asset_type) params.set('asset_type', filters.asset_type)
if (filters.skip !== undefined) params.set('skip', String(filters.skip))
if (filters.limit !== undefined) params.set('limit', String(filters.limit))
return api.get(`/media?${params}`).then(r => r.data)
}
export const getMediaAsset = (id: string): Promise<MediaAsset> =>
api.get(`/media/${id}`).then(r => r.data)
export const downloadMediaAsset = (id: string): void => {
window.open(`/api/media/${id}/download`, '_blank')
}
export const zipDownloadAssets = (ids: string[]): Promise<void> =>
api.post('/media/zip', ids, { responseType: 'blob' }).then(r => {
const url = URL.createObjectURL(r.data)
const a = document.createElement('a')
a.href = url
a.download = 'media-export.zip'
a.click()
URL.revokeObjectURL(url)
})
export const archiveMediaAsset = (id: string): Promise<void> =>
api.delete(`/media/${id}`).then(() => undefined)