feat(phase7.2): media browser with server-side filters + pagination

- Migration 052: indexes on media_assets(asset_type, created_at) and
  products(category_key, lagertyp) for efficient filter queries
- GET /api/media/assets: JOINs media_assets→products→order_lines,
  filters by asset_type / category_key / render_status / q (ILIKE),
  paginated (page/page_size), returns total+pages count
- New schemas: MediaAssetBrowseItem, MediaAssetBrowseResponse
- frontend/src/api/media.ts: getMediaAssets(filters), typed interfaces
- MediaBrowser.tsx: rewritten with sticky filter bar (debounced search,
  type/category/status dropdowns), responsive grid, image previews,
  download buttons, pagination footer with page size selector
- Renamed legacy function to listMediaAssets for backward compat

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 20:24:03 +01:00
parent 89c44b846f
commit c99976cc85
8 changed files with 482 additions and 502 deletions
+106 -2
View File
@@ -1,17 +1,18 @@
"""MediaAsset router — /api/media."""
import io
import math
import uuid
import zipfile
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.domains.auth.models import User
from app.domains.media.models import MediaAsset, MediaAssetType
from app.domains.media.schemas import MediaAssetOut
from app.domains.media.schemas import MediaAssetOut, MediaAssetBrowseItem, MediaAssetBrowseResponse
from app.domains.media import service
from app.utils.auth import get_current_user
@@ -98,6 +99,109 @@ async def list_assets(
return assets
@router.get("/assets", response_model=MediaAssetBrowseResponse)
async def browse_media_assets(
asset_type: str | None = None,
category_key: str | None = None,
render_status: str | None = None,
q: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> MediaAssetBrowseResponse:
"""Media browser: server-side filtered + paginated asset list with product context."""
from app.domains.products.models import Product
from app.domains.orders.models import OrderLine
from sqlalchemy import desc
# Build query with LEFT JOINs to get product and order_line context.
# MediaAsset has direct product_id FK and order_line_id FK.
# OrderLine has render_status which we also want to surface.
stmt = (
select(
MediaAsset,
Product.name.label("product_name"),
Product.pim_id.label("product_pim_id"),
Product.category_key.label("category_key"),
OrderLine.render_status.label("render_status"),
)
.outerjoin(Product, MediaAsset.product_id == Product.id)
.outerjoin(OrderLine, MediaAsset.order_line_id == OrderLine.id)
.where(MediaAsset.is_archived == False) # noqa: E712
.order_by(desc(MediaAsset.created_at))
)
# Apply filters
if asset_type:
try:
at_enum = MediaAssetType(asset_type)
stmt = stmt.where(MediaAsset.asset_type == at_enum)
except ValueError:
pass # invalid type → ignore filter
if category_key:
stmt = stmt.where(Product.category_key == category_key)
if render_status:
stmt = stmt.where(OrderLine.render_status == render_status)
if q:
pattern = f"%{q}%"
from sqlalchemy import or_
stmt = stmt.where(
or_(
Product.name.ilike(pattern),
Product.pim_id.ilike(pattern),
)
)
# Count total matching rows
count_stmt = select(func.count()).select_from(stmt.subquery())
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
# Paginate
offset = (page - 1) * page_size
stmt = stmt.offset(offset).limit(page_size)
rows = await db.execute(stmt)
items: list[MediaAssetBrowseItem] = []
for row in rows.all():
asset: MediaAsset = row[0]
product_name: str | None = row[1]
product_pim_id: str | None = row[2]
cat_key: str | None = row[3]
r_status: str | None = row[4]
item = MediaAssetBrowseItem(
id=asset.id,
asset_type=asset.asset_type,
file_path=asset.storage_key,
file_size_bytes=asset.file_size_bytes,
mime_type=asset.mime_type,
created_at=asset.created_at,
order_line_id=asset.order_line_id,
product_id=asset.product_id,
product_name=product_name,
product_pim_id=product_pim_id,
category_key=cat_key,
render_status=r_status,
download_url=f"/api/media/{asset.id}/download",
thumbnail_url=service.get_thumbnail_url(asset),
)
items.append(item)
pages = max(1, math.ceil(total / page_size))
return MediaAssetBrowseResponse(
items=items,
total=total,
page=page,
page_size=page_size,
pages=pages,
)
@router.get("/{asset_id}", response_model=MediaAssetOut)
async def get_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
asset = await service.get_media_asset(db, asset_id)
+28
View File
@@ -25,3 +25,31 @@ class MediaAssetOut(BaseModel):
thumbnail_url: str | None = None
model_config = {"from_attributes": True}
class MediaAssetBrowseItem(BaseModel):
"""Enriched asset item for the media browser endpoint."""
id: uuid.UUID
asset_type: MediaAssetType
file_path: str
file_size_bytes: int | None
mime_type: str | None
created_at: datetime
order_line_id: uuid.UUID | None
product_id: uuid.UUID | None
product_name: str | None
product_pim_id: str | None
category_key: str | None
render_status: str | None
download_url: str | None = None
thumbnail_url: str | None = None
model_config = {"from_attributes": True}
class MediaAssetBrowseResponse(BaseModel):
items: list[MediaAssetBrowseItem]
total: int
page: int
page_size: int
pages: int