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
@@ -0,0 +1,71 @@
"""Add media_assets table.
Revision ID: 040
Revises: 039
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = '040'
down_revision = '039'
branch_labels = None
depends_on = None
def upgrade():
# Create enum type
op.execute(
"CREATE TYPE media_asset_type AS ENUM ("
"'thumbnail','still','turntable','stl_low','stl_high',"
"'gltf_geometry','gltf_production','blend_production')"
)
op.create_table(
'media_assets',
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
sa.Column('tenant_id', UUID(as_uuid=True), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=True),
sa.Column('product_id', UUID(as_uuid=True), sa.ForeignKey('products.id', ondelete='CASCADE'), nullable=True),
sa.Column('cad_file_id', UUID(as_uuid=True), sa.ForeignKey('cad_files.id', ondelete='SET NULL'), nullable=True),
sa.Column('order_line_id', UUID(as_uuid=True), sa.ForeignKey('order_lines.id', ondelete='SET NULL'), nullable=True),
sa.Column('workflow_run_id', UUID(as_uuid=True), sa.ForeignKey('workflow_runs.id', ondelete='SET NULL'), nullable=True),
sa.Column(
'asset_type',
sa.Enum(
'thumbnail', 'still', 'turntable',
'stl_low', 'stl_high',
'gltf_geometry', 'gltf_production', 'blend_production',
name='media_asset_type',
),
nullable=False,
),
sa.Column('storage_key', sa.Text, nullable=False),
sa.Column('file_size_bytes', sa.BigInteger, nullable=True),
sa.Column('mime_type', sa.String(100), nullable=True),
sa.Column('width', sa.Integer, nullable=True),
sa.Column('height', sa.Integer, nullable=True),
sa.Column('duration_s', sa.Float, nullable=True),
sa.Column('render_config', JSONB, nullable=True),
sa.Column('is_archived', sa.Boolean, nullable=False, server_default='false'),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.text('NOW()')),
)
op.create_index('ix_media_assets_product', 'media_assets', ['product_id'])
op.create_index('ix_media_assets_tenant', 'media_assets', ['tenant_id'])
op.create_index('ix_media_assets_order_line', 'media_assets', ['order_line_id'])
op.create_index('ix_media_assets_asset_type', 'media_assets', ['asset_type'])
# RLS
op.execute("ALTER TABLE media_assets ENABLE ROW LEVEL SECURITY")
op.execute(
"""CREATE POLICY tenant_isolation ON media_assets
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid)"""
)
op.execute(
"""CREATE POLICY admin_bypass ON media_assets
USING (current_setting('app.current_tenant_id', true) = 'bypass')"""
)
def downgrade():
op.drop_table('media_assets')
op.execute("DROP TYPE IF EXISTS media_asset_type")