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:
@@ -0,0 +1,31 @@
|
||||
"""Add indexes for media browser filtering.
|
||||
|
||||
Revision ID: 052
|
||||
Revises: 051
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "052"
|
||||
down_revision = "051"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Index for filtering media_assets by asset_type + created_at ordering
|
||||
op.create_index(
|
||||
"ix_media_assets_asset_type_created",
|
||||
"media_assets",
|
||||
["asset_type", "created_at"],
|
||||
)
|
||||
# Index for filtering products by category + lagertyp
|
||||
op.create_index(
|
||||
"ix_products_category_lagertyp",
|
||||
"products",
|
||||
["category_key", "lagertyp"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_products_category_lagertyp", table_name="products")
|
||||
op.drop_index("ix_media_assets_asset_type_created", table_name="media_assets")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user