feat(studio,stacking,animations,collections): M16 ModBison Studio, object stacking, model animations, and poly collections for MVP 2
checkpoint / checkpoint (push) Successful in 6m32s

This commit is contained in:
2026-09-22 18:30:57 +00:00
parent 654c1ec6bf
commit 721dbe5ae7
37 changed files with 5202 additions and 17 deletions
@@ -0,0 +1,53 @@
"""add asset_collections and collection_assets tables
Revision ID: 0017_add_asset_collections
Revises: 0016_content_guidelines
Create Date: 2026-09-22 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0017_add_asset_collections"
down_revision: Union[str, None] = "0016_content_guidelines"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = sa.inspect(bind)
tables = insp.get_table_names()
if "asset_collections" not in tables:
op.create_table(
"asset_collections",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("title", sa.String(length=255), nullable=False, index=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("theme", sa.String(length=64), nullable=False, server_default="general", index=True),
sa.Column("thumbnail_url", sa.String(length=512), nullable=True),
sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True),
sa.Column("tags", sa.String(length=512), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
if "collection_assets" not in tables:
op.create_table(
"collection_assets",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("collection_id", sa.Integer(), sa.ForeignKey("asset_collections.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("asset_id", sa.Integer(), sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("collection_assets")
op.drop_table("asset_collections")
+3 -1
View File
@@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import (
auth, users, worlds, assets, avatar, world_content, internal,
auth, users, worlds, assets, asset_collections, avatar, world_content, internal,
presence, groups, social_friends, profiles, chat_dm, chat_reports,
chat_world, wallet, payments, marketplace, payouts, discovery,
)
@@ -47,6 +47,8 @@ app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
app.include_router(world_content.router, prefix="/api/worlds", tags=["world-content"])
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(asset_collections.router, prefix="/api/assets/collections", tags=["asset-collections"])
app.include_router(asset_collections.router, prefix="/assets/collections", tags=["asset-collections"])
app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"])
app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
+2 -1
View File
@@ -2,6 +2,7 @@ from app.models.user import User
from app.models.session import Session
from app.models.world import World
from app.models.asset import Asset
from app.models.asset_collection import AssetCollection, CollectionAsset
from app.models.avatar_loadout import AvatarLoadout
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
from app.models.social import Friendship, Follow, Block, FriendshipStatus
@@ -18,7 +19,7 @@ from app.models.marketplace import (
from app.models.world_visit import WorldVisit
__all__ = [
"User", "Session", "World", "Asset", "AvatarLoadout",
"User", "Session", "World", "Asset", "AssetCollection", "CollectionAsset", "AvatarLoadout",
"WorldRoom", "WorldChunk", "WorldChunkRevision",
"Friendship", "Follow", "Block", "FriendshipStatus",
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
+53
View File
@@ -0,0 +1,53 @@
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class AssetCollection(Base):
"""A curated collection or pack of 3D models and assets."""
__tablename__ = "asset_collections"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False, index=True)
description = Column(Text, nullable=True)
theme = Column(String(64), nullable=False, default="general", index=True)
thumbnail_url = Column(String(512), nullable=True)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
is_public = Column(Boolean, default=True, nullable=False, index=True)
tags = Column(String(512), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
owner = relationship("User", back_populates="asset_collections")
items = relationship(
"CollectionAsset",
back_populates="collection",
cascade="all, delete-orphan",
order_by="CollectionAsset.sort_order",
)
class CollectionAsset(Base):
"""Join entity linking an Asset to an AssetCollection with ordering."""
__tablename__ = "collection_assets"
id = Column(Integer, primary_key=True, index=True)
collection_id = Column(
Integer,
ForeignKey("asset_collections.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
asset_id = Column(
Integer,
ForeignKey("assets.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
sort_order = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
collection = relationship("AssetCollection", back_populates="items")
asset = relationship("Asset")
+1
View File
@@ -28,6 +28,7 @@ class User(Base):
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
worlds = relationship("World", back_populates="owner")
assets = relationship("Asset", back_populates="owner")
asset_collections = relationship("AssetCollection", back_populates="owner", cascade="all, delete-orphan")
avatar_loadout = relationship(
"AvatarLoadout",
back_populates="user",
+458
View File
@@ -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)
+4 -3
View File
@@ -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}
+235
View File
@@ -0,0 +1,235 @@
"""Integration tests for Poly Model Asset Collections and ZIP batch extraction.
Covers tasks dfe19da5a448 and 840bb736d098.
"""
import io
import zipfile
import pytest
from fastapi.testclient import TestClient
from app.main import app
def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]:
resp = client.post(
"/api/auth/register",
json={
"username": username,
"email": f"{username}@example.com",
"password": "secret123",
"date_of_birth": "1990-01-01",
},
)
assert resp.status_code == 201, resp.text
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
me = client.get("/api/users/me", headers=headers)
assert me.status_code == 200, me.text
return me.json()["id"], headers
def test_collection_crud_and_permissions():
client = TestClient(app)
uid1, h1 = _register(client, "col_user_1")
uid2, h2 = _register(client, "col_user_2")
# 1. Create collection as user 1
resp = client.post(
"/api/assets/collections/",
headers=h1,
json={
"title": "Low Poly Fantasy Props",
"description": "Swords, shields, and potions",
"theme": "fantasy",
"is_public": True,
"tags": "fantasy,lowpoly,weapons",
},
)
assert resp.status_code == 201, resp.text
col_data = resp.json()
assert col_data["title"] == "Low Poly Fantasy Props"
assert col_data["theme"] == "fantasy"
assert col_data["owner_id"] == uid1
col_id = col_data["id"]
# 2. List collections with theme filter
resp = client.get("/api/assets/collections/", headers=h2, params={"theme": "fantasy"})
assert resp.status_code == 200
cols = resp.json()
assert any(c["id"] == col_id for c in cols)
# 3. List with non-matching theme filter
resp = client.get("/api/assets/collections/", headers=h2, params={"theme": "scifi"})
assert resp.status_code == 200
cols = resp.json()
assert not any(c["id"] == col_id for c in cols)
# 4. Search filter
resp = client.get("/api/assets/collections/", headers=h2, params={"search": "shields"})
assert resp.status_code == 200
assert any(c["id"] == col_id for c in resp.json())
# 5. User 2 views public collection
resp = client.get(f"/api/assets/collections/{col_id}", headers=h2)
assert resp.status_code == 200
assert resp.json()["id"] == col_id
# 6. User 2 cannot modify User 1's collection
resp = client.patch(
f"/api/assets/collections/{col_id}",
headers=h2,
json={"title": "Hacked Title"},
)
assert resp.status_code == 403
# 7. User 2 cannot delete User 1's collection
resp = client.delete(f"/api/assets/collections/{col_id}", headers=h2)
assert resp.status_code == 403
# 8. User 1 updates collection to private
resp = client.patch(
f"/api/assets/collections/{col_id}",
headers=h1,
json={"is_public": False, "theme": "medieval"},
)
assert resp.status_code == 200
assert resp.json()["is_public"] is False
assert resp.json()["theme"] == "medieval"
# 9. User 2 can no longer view private collection
resp = client.get(f"/api/assets/collections/{col_id}", headers=h2)
assert resp.status_code == 403
# 10. User 1 deletes collection
resp = client.delete(f"/api/assets/collections/{col_id}", headers=h1)
assert resp.status_code == 204
# 11. Verify gone
resp = client.get(f"/api/assets/collections/{col_id}", headers=h1)
assert resp.status_code == 404
def test_collection_item_association_and_ordering():
client = TestClient(app)
uid, h = _register(client, "col_items_user")
# Create 2 assets
a1_resp = client.post(
"/api/assets/",
headers=h,
json={"name": "Wooden Crate", "asset_type": "model_3d"},
)
assert a1_resp.status_code == 201, a1_resp.text
asset1_id = a1_resp.json()["id"]
a2_resp = client.post(
"/api/assets/",
headers=h,
json={"name": "Iron Barrel", "asset_type": "model_3d"},
)
assert a2_resp.status_code == 201, a2_resp.text
asset2_id = a2_resp.json()["id"]
# Create collection
col_resp = client.post(
"/api/assets/collections/",
headers=h,
json={"title": "Industrial Pack", "theme": "industrial"},
)
assert col_resp.status_code == 201
col_id = col_resp.json()["id"]
# Add items with explicit ordering
add1 = client.post(
f"/api/assets/collections/{col_id}/items",
headers=h,
json={"asset_id": asset1_id, "sort_order": 0},
)
assert add1.status_code == 200
assert add1.json()["items_count"] == 1
assert add1.json()["items"][0]["id"] == asset1_id
add2 = client.post(
f"/api/assets/collections/{col_id}/items",
headers=h,
json={"asset_id": asset2_id, "sort_order": 1},
)
assert add2.status_code == 200
assert add2.json()["items_count"] == 2
assert add2.json()["items"][1]["id"] == asset2_id
# Remove item 1
rem = client.delete(
f"/api/assets/collections/{col_id}/items/{asset1_id}",
headers=h,
)
assert rem.status_code == 200
assert rem.json()["items_count"] == 1
assert rem.json()["items"][0]["id"] == asset2_id
def test_collection_zip_upload_and_extraction():
client = TestClient(app)
uid, h = _register(client, "col_zip_user")
# 1. Build a valid in-memory zip file with mock GLB content
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("stone_pillar.glb", b"glTFmockheader_binary_stone_pillar")
zf.writestr("wooden_arch.glb", b"glTFmockheader_binary_wooden_arch")
zf.writestr("temple_roof.gltf", b'{"asset": {"version": "2.0"}}')
zf.writestr("readme.txt", b"Instructions for this pack")
zip_bytes = zip_buf.getvalue()
# Upload zip pack
resp = client.post(
"/api/assets/collections/upload-zip",
headers=h,
data={
"title": "Ancient Ruins Pack",
"theme": "historical",
"description": "Set of stone pillars and wooden arches",
"is_public": "true",
"tags": "ruins,ancient,stone",
},
files={"file": ("ruins_pack.zip", zip_bytes, "application/zip")},
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["title"] == "Ancient Ruins Pack"
assert data["theme"] == "historical"
assert data["items_count"] == 3
names = [i["name"] for i in data["items"]]
assert "Stone Pillar" in names
assert "Wooden Arch" in names
assert "Temple Roof" in names
def test_collection_zip_error_handling():
client = TestClient(app)
uid, h = _register(client, "col_badzip_user")
# 1. Invalid zip payload
resp = client.post(
"/api/assets/collections/upload-zip",
headers=h,
data={"title": "Corrupt Pack", "theme": "general"},
files={"file": ("bad.zip", b"not-a-valid-zip-file", "application/zip")},
)
assert resp.status_code == 400
assert "Invalid ZIP archive" in resp.json()["detail"]
# 2. Valid zip but no 3D models
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("notes.txt", b"Hello world")
zf.writestr("image.png", b"fake image bytes")
resp = client.post(
"/api/assets/collections/upload-zip",
headers=h,
data={"title": "No Models Pack", "theme": "general"},
files={"file": ("empty.zip", zip_buf.getvalue(), "application/zip")},
)
assert resp.status_code == 400
assert "no valid 3D models" in resp.json()["detail"]