"""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)