Files
modbison/backend/app/routers/asset_collections.py
T

459 lines
15 KiB
Python

"""Asset collection router supporting CRUD, theme filtering, and ZIP multi-model extraction."""
import io
import json
import os
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
import zipfile
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Form
from pydantic import BaseModel, Field
from sqlalchemy import select, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.asset import Asset, AssetType
from app.models.asset_collection import AssetCollection, CollectionAsset
from app.services import gltf as gltf_service
router = APIRouter()
# --- Pydantic Schemas ---
class CollectionCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
theme: str = Field("general", max_length=64)
thumbnail_url: Optional[str] = None
is_public: bool = True
tags: Optional[str] = None
class CollectionUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
theme: Optional[str] = Field(None, max_length=64)
thumbnail_url: Optional[str] = None
is_public: Optional[bool] = None
tags: Optional[str] = None
class CollectionItemAdd(BaseModel):
asset_id: int
sort_order: Optional[int] = 0
class AssetItemResponse(BaseModel):
id: int
name: str
description: Optional[str] = None
asset_type: str
file_path: str
file_size: Optional[int] = None
sort_order: int = 0
model_config = {"from_attributes": True}
class CollectionResponse(BaseModel):
id: int
title: str
description: Optional[str] = None
theme: str
thumbnail_url: Optional[str] = None
owner_id: int
is_public: bool
tags: Optional[str] = None
created_at: Union[datetime, str]
updated_at: Union[datetime, str]
items_count: int = 0
items: list[AssetItemResponse] = []
model_config = {"from_attributes": True}
# --- Helpers ---
def _sanitize_path(filename: str) -> str:
"""Strip directories and non-safe characters."""
base = os.path.basename(filename.replace("\\", "/"))
safe = "".join(c for c in base if c.isalnum() or c in "._-")
return safe or "model.glb"
# --- Endpoints ---
@router.post("/", response_model=CollectionResponse, status_code=201)
async def create_collection(
payload: CollectionCreate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Create a new asset collection."""
col = AssetCollection(
title=payload.title,
description=payload.description,
theme=payload.theme.lower().strip(),
thumbnail_url=payload.thumbnail_url,
owner_id=current_user.id,
is_public=payload.is_public,
tags=payload.tags,
)
db.add(col)
await db.commit()
await db.refresh(col)
return CollectionResponse(
id=col.id,
title=col.title,
description=col.description,
theme=col.theme,
thumbnail_url=col.thumbnail_url,
owner_id=col.owner_id,
is_public=col.is_public,
tags=col.tags,
created_at=str(col.created_at),
updated_at=str(col.updated_at),
items_count=0,
items=[],
)
@router.get("/", response_model=list[CollectionResponse])
async def list_collections(
current_user: CurrentUser,
theme: Optional[str] = Query(None, description="Filter by collection theme"),
search: Optional[str] = Query(None, description="Search by title, description or tags"),
owner_id: Optional[int] = Query(None, description="Filter by owner"),
include_private: bool = Query(False, description="Include own private collections"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
"""List asset collections with optional theme filtering, search, and pagination."""
stmt = select(AssetCollection).options(
selectinload(AssetCollection.items).selectinload(CollectionAsset.asset)
)
# Visibility: public collections or own collections
if include_private:
stmt = stmt.where(or_(AssetCollection.is_public == True, AssetCollection.owner_id == current_user.id))
else:
stmt = stmt.where(or_(AssetCollection.is_public == True, AssetCollection.owner_id == current_user.id))
if theme:
stmt = stmt.where(AssetCollection.theme == theme.lower().strip())
if owner_id:
stmt = stmt.where(AssetCollection.owner_id == owner_id)
if search:
s = f"%{search.strip()}%"
stmt = stmt.where(
or_(
AssetCollection.title.ilike(s),
AssetCollection.description.ilike(s),
AssetCollection.tags.ilike(s),
)
)
stmt = stmt.order_by(AssetCollection.created_at.desc()).limit(limit).offset(offset)
result = await db.execute(stmt)
collections = result.scalars().all()
resp: list[CollectionResponse] = []
for col in collections:
items_resp: list[AssetItemResponse] = []
for item in col.items:
if item.asset is not None:
items_resp.append(
AssetItemResponse(
id=item.asset.id,
name=item.asset.name,
description=item.asset.description,
asset_type=str(item.asset.asset_type.value if hasattr(item.asset.asset_type, "value") else item.asset.asset_type),
file_path=item.asset.file_path,
file_size=item.asset.file_size,
sort_order=item.sort_order,
)
)
resp.append(
CollectionResponse(
id=col.id,
title=col.title,
description=col.description,
theme=col.theme,
thumbnail_url=col.thumbnail_url,
owner_id=col.owner_id,
is_public=col.is_public,
tags=col.tags,
created_at=str(col.created_at),
updated_at=str(col.updated_at),
items_count=len(items_resp),
items=items_resp,
)
)
return resp
@router.get("/{collection_id}", response_model=CollectionResponse)
async def get_collection(
collection_id: int,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Get single collection by ID with all item details."""
stmt = (
select(AssetCollection)
.options(selectinload(AssetCollection.items).selectinload(CollectionAsset.asset))
.where(AssetCollection.id == collection_id)
)
res = await db.execute(stmt)
col = res.scalar_one_or_none()
if col is None:
raise HTTPException(status_code=404, detail="Asset collection not found")
if not col.is_public and col.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Private collection")
items_resp: list[AssetItemResponse] = []
for item in col.items:
if item.asset is not None:
items_resp.append(
AssetItemResponse(
id=item.asset.id,
name=item.asset.name,
description=item.asset.description,
asset_type=str(item.asset.asset_type.value if hasattr(item.asset.asset_type, "value") else item.asset.asset_type),
file_path=item.asset.file_path,
file_size=item.asset.file_size,
sort_order=item.sort_order,
)
)
return CollectionResponse(
id=col.id,
title=col.title,
description=col.description,
theme=col.theme,
thumbnail_url=col.thumbnail_url,
owner_id=col.owner_id,
is_public=col.is_public,
tags=col.tags,
created_at=str(col.created_at),
updated_at=str(col.updated_at),
items_count=len(items_resp),
items=items_resp,
)
@router.patch("/{collection_id}", response_model=CollectionResponse)
async def update_collection(
collection_id: int,
payload: CollectionUpdate,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Update collection metadata (owner only)."""
col = await db.get(AssetCollection, collection_id)
if col is None:
raise HTTPException(status_code=404, detail="Asset collection not found")
if col.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not collection owner")
up_data = payload.model_dump(exclude_unset=True)
for k, v in up_data.items():
if k == "theme" and v is not None:
v = str(v).lower().strip()
setattr(col, k, v)
await db.commit()
await db.refresh(col)
return await get_collection(collection_id, current_user, db)
@router.delete("/{collection_id}", status_code=204)
async def delete_collection(
collection_id: int,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Delete a collection (owner only)."""
col = await db.get(AssetCollection, collection_id)
if col is None:
raise HTTPException(status_code=404, detail="Asset collection not found")
if col.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not collection owner")
await db.delete(col)
await db.commit()
@router.post("/{collection_id}/items", response_model=CollectionResponse)
async def add_item_to_collection(
collection_id: int,
payload: CollectionItemAdd,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Add an asset to the collection (owner only)."""
col = await db.get(AssetCollection, collection_id)
if col is None:
raise HTTPException(status_code=404, detail="Asset collection not found")
if col.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not collection owner")
asset = await db.get(Asset, payload.asset_id)
if asset is None:
raise HTTPException(status_code=404, detail="Asset not found")
# Check if already added
chk = await db.execute(
select(CollectionAsset).where(
CollectionAsset.collection_id == collection_id,
CollectionAsset.asset_id == payload.asset_id,
)
)
existing = chk.scalar_one_or_none()
if existing is not None:
existing.sort_order = payload.sort_order or existing.sort_order
else:
new_link = CollectionAsset(
collection_id=collection_id,
asset_id=payload.asset_id,
sort_order=payload.sort_order or 0,
)
db.add(new_link)
await db.commit()
return await get_collection(collection_id, current_user, db)
@router.delete("/{collection_id}/items/{asset_id}", response_model=CollectionResponse)
async def remove_item_from_collection(
collection_id: int,
asset_id: int,
current_user: CurrentUser,
db: AsyncSession = Depends(get_db),
):
"""Remove an asset from the collection (owner only)."""
col = await db.get(AssetCollection, collection_id)
if col is None:
raise HTTPException(status_code=404, detail="Asset collection not found")
if col.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not collection owner")
chk = await db.execute(
select(CollectionAsset).where(
CollectionAsset.collection_id == collection_id,
CollectionAsset.asset_id == asset_id,
)
)
existing = chk.scalar_one_or_none()
if existing is not None:
await db.delete(existing)
await db.commit()
return await get_collection(collection_id, current_user, db)
@router.post("/upload-zip", response_model=CollectionResponse, status_code=201)
async def upload_collection_zip(
current_user: CurrentUser,
file: UploadFile = File(...),
title: str = Form(...),
theme: str = Form("general"),
description: Optional[str] = Form(None),
is_public: bool = Form(True),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Upload a ZIP archive, safely extract .glb / .gltf models, create Asset entries, and pack into AssetCollection."""
content = await file.read()
if not zipfile.is_zipfile(io.BytesIO(content)):
raise HTTPException(status_code=400, detail="Invalid ZIP archive")
upload_dir = Path(settings.UPLOAD_DIR) / "collections"
upload_dir.mkdir(parents=True, exist_ok=True)
# 1. Create collection record first
col = AssetCollection(
title=title,
description=description,
theme=theme.lower().strip(),
owner_id=current_user.id,
is_public=is_public,
tags=tags,
)
db.add(col)
await db.flush()
extracted_assets: list[Asset] = []
sort_idx = 0
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
for entry in zf.infolist():
if entry.is_dir():
continue
clean_name = _sanitize_path(entry.filename)
ext = clean_name.rsplit(".", 1)[-1].lower() if "." in clean_name else ""
if ext not in ["glb", "gltf", "obj"]:
continue
model_bytes = zf.read(entry.filename)
if len(model_bytes) == 0:
continue
# Save file to disk
stored_filename = f"col_{col.id}_{sort_idx}_{clean_name}"
dest_path = upload_dir / stored_filename
dest_path.write_bytes(model_bytes)
# Create asset
base_name = clean_name.rsplit(".", 1)[0].replace("_", " ").title()
asset = Asset(
name=base_name,
description=f"Extracted from collection '{title}' ({clean_name})",
asset_type=AssetType.MODEL_3D,
file_path=str(dest_path),
file_size=len(model_bytes),
mime_type="model/gltf-binary" if ext == "glb" else "model/gltf+json",
owner_id=current_user.id,
is_public=is_public,
)
db.add(asset)
await db.flush()
link = CollectionAsset(
collection_id=col.id,
asset_id=asset.id,
sort_order=sort_idx,
)
db.add(link)
extracted_assets.append(asset)
sort_idx += 1
if not extracted_assets:
raise HTTPException(status_code=400, detail="ZIP archive contains no valid 3D models (.glb, .gltf)")
await db.commit()
await db.refresh(col)
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"Failed to extract collection archive: {exc}")
return await get_collection(col.id, current_user, db)