459 lines
15 KiB
Python
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)
|