feat(studio,stacking,animations,collections): M16 ModBison Studio, object stacking, model animations, and poly collections for MVP 2
checkpoint / checkpoint (push) Successful in 6m32s
checkpoint / checkpoint (push) Successful in 6m32s
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
"""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)
|
||||
@@ -3,7 +3,8 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from typing import Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -43,8 +44,8 @@ class AssetResponse(BaseModel):
|
||||
world_id: Optional[int]
|
||||
owner_id: int
|
||||
is_public: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
created_at: Union[datetime, str]
|
||||
updated_at: Union[datetime, str]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user