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
+12
View File
@@ -14,6 +14,7 @@ Environment variables (set in docker-compose.yml):
If MINIO_URL is not set, falls back to LocalStorage (reads/writes to UPLOAD_DIR).
"""
import io
import logging
import os
from pathlib import Path
@@ -91,6 +92,12 @@ class MinIOStorage:
ExpiresIn=expires_in,
)
def download_bytes(self, object_key: str) -> bytes:
"""Download file from MinIO and return as bytes."""
buf = io.BytesIO()
self._client.download_fileobj(self._bucket, object_key, buf)
return buf.getvalue()
@property
def backend(self) -> str:
return "minio"
@@ -138,6 +145,11 @@ class LocalStorage:
def get_url(self, object_key: str, expires_in: int = 3600) -> str:
return f"/api/files/{object_key}"
def download_bytes(self, object_key: str) -> bytes:
"""Read file from local storage and return as bytes."""
path = self._resolve(object_key)
return path.read_bytes()
@property
def backend(self) -> str:
return "local"