Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc0cf80000 | |||
| a5cdabf6de | |||
| a9137f6505 | |||
| 98bbe67406 | |||
| ffbc4849a7 | |||
| a29cfc74af | |||
| 073813f3e7 | |||
| cc971ee9f5 | |||
| 54abe7ee96 | |||
| a7cf385d0d | |||
| b5525665dd |
@@ -4,10 +4,13 @@ DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# JWT
|
||||
SECRET_KEY=change-me-to-a-secure-random-string
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
# JWT / Auth
|
||||
ALGORITHM=RS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
# Directory where the RSA keypair is loaded/generated at runtime.
|
||||
# Defaults to ~/.agp/keys (outside the repo). Keys are never committed to git.
|
||||
RSA_KEY_DIR=~/.agp/keys
|
||||
|
||||
# App
|
||||
APP_NAME=AGP Backend
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add refresh_tokens table
|
||||
|
||||
Revision ID: 0003_add_refresh_tokens_table
|
||||
Revises: 0002_add_date_of_birth_to_users
|
||||
Create Date: 2025-07-25 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0003_add_refresh_tokens_table"
|
||||
down_revision: Union[str, None] = "0002_add_date_of_birth_to_users"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the refresh_tokens table."""
|
||||
op.create_table(
|
||||
"refresh_tokens",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("token", sa.String(512), unique=True, index=True, nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the refresh_tokens table."""
|
||||
op.drop_table("refresh_tokens")
|
||||
+80
-5
@@ -1,10 +1,56 @@
|
||||
"""Application configuration via pydantic-settings."""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import (
|
||||
RSAPrivateKey,
|
||||
RSAPublicKey,
|
||||
generate_private_key,
|
||||
)
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
PublicFormat,
|
||||
load_pem_private_key,
|
||||
load_pem_public_key,
|
||||
)
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _load_or_generate_rsa_keys(key_dir: Path) -> tuple[bytes, bytes]:
|
||||
"""Load existing RSA keys or generate a new pair.
|
||||
|
||||
Keys are stored as PEM files in *key_dir*. The directory is created
|
||||
if it does not exist. Files are named ``private.pem`` and
|
||||
``public.pem`` and are **not** committed to git.
|
||||
"""
|
||||
private_path = key_dir / "private.pem"
|
||||
public_path = key_dir / "public.pem"
|
||||
|
||||
if private_path.exists() and public_path.exists():
|
||||
private_pem = private_path.read_bytes()
|
||||
public_pem = public_path.read_bytes()
|
||||
else:
|
||||
# Generate a new 2048-bit RSA key pair
|
||||
private_key = generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = private_key.private_bytes(
|
||||
Encoding.PEM,
|
||||
PrivateFormat.PKCS8,
|
||||
NoEncryption(),
|
||||
)
|
||||
public_pem = private_key.public_key().public_bytes(
|
||||
Encoding.PEM,
|
||||
PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
key_dir.mkdir(parents=True, exist_ok=True)
|
||||
private_path.write_bytes(private_pem)
|
||||
public_path.write_bytes(public_pem)
|
||||
|
||||
return private_pem, public_pem
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# App
|
||||
APP_NAME: str = "AGP Backend"
|
||||
@@ -18,11 +64,13 @@ class Settings(BaseSettings):
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT / Auth
|
||||
# NOTE: production must override SECRET_KEY with a secure random value
|
||||
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
|
||||
SECRET_KEY: str = "change-me-to-a-secure-random-string"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
ALGORITHM: str = "RS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||
|
||||
# RS256 key storage. Defaults to a directory outside the repo so the
|
||||
# generated keypair is never accidentally committed.
|
||||
RSA_KEY_DIR: str = str(Path.home() / ".agp" / "keys")
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
|
||||
@@ -30,6 +78,7 @@ class Settings(BaseSettings):
|
||||
# File uploads
|
||||
UPLOAD_DIR: str = "uploads"
|
||||
MAX_UPLOAD_SIZE_MB: int = 100
|
||||
GLTF_MAX_FILE_SIZE_MB: int = 200
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
@@ -41,6 +90,32 @@ class Settings(BaseSettings):
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
||||
|
||||
@property
|
||||
def rsa_key_dir(self) -> Path:
|
||||
return Path(self.RSA_KEY_DIR)
|
||||
|
||||
@property
|
||||
def private_pem(self) -> bytes:
|
||||
"""Return the private key PEM bytes (loads/generates on first call)."""
|
||||
private_pem, _ = _load_or_generate_rsa_keys(self.rsa_key_dir)
|
||||
return private_pem
|
||||
|
||||
@property
|
||||
def public_pem(self) -> bytes:
|
||||
"""Return the public key PEM bytes (loads/generates on first call)."""
|
||||
_, public_pem = _load_or_generate_rsa_keys(self.rsa_key_dir)
|
||||
return public_pem
|
||||
|
||||
@property
|
||||
def private_key(self) -> RSAPrivateKey:
|
||||
"""Return the deserialized RSA private key object."""
|
||||
return load_pem_private_key(self.private_pem, password=None)
|
||||
|
||||
@property
|
||||
def public_key(self) -> RSAPublicKey:
|
||||
"""Return the deserialized RSA public key object."""
|
||||
return load_pem_public_key(self.public_pem)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -27,7 +27,7 @@ async def get_current_user(
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
token, settings.public_pem, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
user_id: str | None = payload.get("sub")
|
||||
if user_id is None:
|
||||
|
||||
@@ -3,6 +3,7 @@ import uuid
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
@@ -18,3 +19,19 @@ class Session(Base):
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", back_populates="sessions")
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
# RS256 JWTs are ~420+ chars; Text avoids length issues and is fine for
|
||||
# a UUID-indexed lookup by hash in the future.
|
||||
token = Column(String, unique=True, index=True, nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", back_populates="refresh_tokens")
|
||||
|
||||
@@ -21,6 +21,7 @@ class User(Base):
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
||||
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
||||
worlds = relationship("World", back_populates="owner")
|
||||
assets = relationship("Asset", back_populates="owner")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Asset management router."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -13,6 +14,7 @@ from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.asset import Asset, AssetType
|
||||
from app.models.world import World
|
||||
from app.services import gltf as gltf_service
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter()
|
||||
@@ -64,6 +66,14 @@ def _sanitize_filename(filename: str) -> str:
|
||||
return safe or "file"
|
||||
|
||||
|
||||
def _detect_asset_type(filename: Optional[str], content: bytes) -> AssetType:
|
||||
"""Detect the asset type from filename and content."""
|
||||
if filename and gltf_service.looks_like_gltf(content, filename):
|
||||
return AssetType.MODEL_3D
|
||||
# Fallback: let the caller decide based on mime_type or default.
|
||||
return AssetType.OTHER
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/", response_model=AssetResponse, status_code=201)
|
||||
@@ -183,10 +193,16 @@ async def upload_asset_file(
|
||||
# Sanitize filename to prevent path traversal.
|
||||
safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}")
|
||||
|
||||
# Validate file size against configured maximum.
|
||||
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
# Read the file content.
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
# Determine which size limit to apply.
|
||||
if gltf_service.looks_like_gltf(content, file.filename):
|
||||
max_bytes = settings.GLTF_MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
else:
|
||||
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||
|
||||
if file_size > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
@@ -200,10 +216,28 @@ async def upload_asset_file(
|
||||
file_path = upload_dir / stored_name
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Detect asset type from content/filename.
|
||||
detected_type = _detect_asset_type(file.filename, content)
|
||||
|
||||
# Update asset record.
|
||||
asset.file_path = str(file_path)
|
||||
asset.mime_type = file.content_type
|
||||
asset.file_size = file_size
|
||||
|
||||
# If this is a glTF file, extract and store metadata.
|
||||
if detected_type == AssetType.MODEL_3D:
|
||||
try:
|
||||
metadata = gltf_service.extract_gltf_metadata(content)
|
||||
asset.metadata_json = json.dumps(metadata)
|
||||
# Also set the asset type to model_3d if it was auto-detected.
|
||||
if asset.asset_type == AssetType.OTHER:
|
||||
asset.asset_type = AssetType.MODEL_3D
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid glTF file: {exc}",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
|
||||
+67
-11
@@ -1,20 +1,21 @@
|
||||
"""Authentication router - login, register, token refresh."""
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import CurrentUser
|
||||
from app.models.session import RefreshToken
|
||||
from app.models.user import User
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
@@ -25,7 +26,9 @@ router = APIRouter()
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
access_token_expires_minutes: int
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
@@ -59,12 +62,31 @@ class UserResponse(BaseModel):
|
||||
# --- Helpers ---
|
||||
|
||||
def _create_access_token(data: dict) -> str:
|
||||
"""Create a signed access token using RS256."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def _create_refresh_token(data: dict) -> str:
|
||||
"""Create a signed refresh token using RS256."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
days=settings.REFRESH_TOKEN_EXPIRE_DAYS
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(
|
||||
to_encode,
|
||||
settings.private_pem,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
@@ -77,7 +99,7 @@ def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||
def calculate_age(birth_date, today=None) -> int:
|
||||
"""Calculate age in years from a birth date.
|
||||
|
||||
Args:
|
||||
@@ -97,11 +119,25 @@ def calculate_age(birth_date: date, today: date | None = None) -> int:
|
||||
return age
|
||||
|
||||
|
||||
def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
||||
"""Create and persist a refresh token for the given user."""
|
||||
refresh_token_value = _create_refresh_token({"sub": str(user_id)})
|
||||
refresh_token_record = RefreshToken(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
token=refresh_token_value,
|
||||
expires_at=datetime.now(timezone.utc)
|
||||
+ timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
|
||||
)
|
||||
db.add(refresh_token_record)
|
||||
return refresh_token_value
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new user and return an access token.
|
||||
"""Register a new user and return access + refresh tokens.
|
||||
|
||||
Requires date_of_birth and rejects users under 18.
|
||||
"""
|
||||
@@ -141,13 +177,23 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Create access token
|
||||
access_token = _create_access_token({"sub": str(user.id)})
|
||||
return TokenResponse(access_token=access_token)
|
||||
|
||||
# Create and persist refresh token
|
||||
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||
await db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_value,
|
||||
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
||||
"""Authenticate user and return an access token.
|
||||
"""Authenticate user and return access + refresh tokens.
|
||||
|
||||
Accepts form-encoded credentials (username/password) as expected by
|
||||
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
|
||||
@@ -167,8 +213,18 @@ async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession =
|
||||
detail="User account is inactive",
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token = _create_access_token({"sub": str(user.id)})
|
||||
return TokenResponse(access_token=access_token)
|
||||
|
||||
# Create and persist refresh token
|
||||
refresh_token_value = _issue_refresh_token(user.id, db)
|
||||
await db.commit()
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_value,
|
||||
access_token_expires_minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Lightweight glTF / GLB metadata extraction.
|
||||
|
||||
This module intentionally avoids heavyweight dependencies such as trimesh or
|
||||
pygltflib. For the metadata fields the asset upload pipeline needs (mesh and
|
||||
vertex counts, animation presence, texture references, and the bounding box)
|
||||
a small amount of glTF-specific parsing is sufficient and keeps the backend
|
||||
dependency-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
from typing import Any, Optional
|
||||
|
||||
# GLB binary header magic bytes ("glTF").
|
||||
_GLB_MAGIC = b"glTF"
|
||||
_GLB_MAGIC_INT = 0x46546C67 # b"glTF" interpreted as little-endian uint32
|
||||
_GLB_JSON_CHUNK_TYPE = 0x4E4F534A # "JSON"
|
||||
_GLB_BIN_CHUNK_TYPE = 0x004E4942 # "BIN\0"
|
||||
|
||||
# Whitespace allowed around a GLB JSON chunk. The spec pads with spaces (0x20),
|
||||
# but be lenient with null bytes and other whitespace that real exporters emit.
|
||||
_JSON_CHUNK_TRIM = b" \t\r\n\x00"
|
||||
|
||||
|
||||
def looks_like_gltf(content: bytes, filename: Optional[str] = None) -> bool:
|
||||
"""Return True when the bytes or filename indicate a glTF/GLB asset."""
|
||||
if content.startswith(_GLB_MAGIC):
|
||||
return True
|
||||
|
||||
if filename:
|
||||
lowered = filename.lower()
|
||||
if lowered.endswith(".gltf") or lowered.endswith(".glb"):
|
||||
return True
|
||||
|
||||
# A bare JSON buffer (e.g. uploaded without an extension) that has the
|
||||
# glTF asset marker is also a glTF file.
|
||||
stripped = content.lstrip()
|
||||
if stripped.startswith(b"{"):
|
||||
try:
|
||||
payload = json.loads(stripped.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return False
|
||||
return isinstance(payload, dict) and "asset" in payload and "version" in payload.get("asset", {})
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _parse_glb(content: bytes) -> dict[str, Any]:
|
||||
"""Parse the JSON chunk of a GLB binary container."""
|
||||
if len(content) < 12:
|
||||
raise ValueError("GLB file is too short")
|
||||
|
||||
magic, _version, _length = struct.unpack_from("<III", content, 0)
|
||||
if magic != _GLB_MAGIC_INT:
|
||||
raise ValueError("invalid GLB magic")
|
||||
|
||||
offset = 12
|
||||
json_chunk: Optional[dict[str, Any]] = None
|
||||
|
||||
while offset + 8 <= len(content):
|
||||
chunk_length, chunk_type = struct.unpack_from("<II", content, offset)
|
||||
offset += 8
|
||||
chunk_end = offset + chunk_length
|
||||
if chunk_end > len(content):
|
||||
raise ValueError("GLB chunk overruns file boundary")
|
||||
|
||||
chunk_data = content[offset:chunk_end]
|
||||
if chunk_type == _GLB_JSON_CHUNK_TYPE:
|
||||
try:
|
||||
json_chunk = json.loads(chunk_data.strip(_JSON_CHUNK_TRIM).decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError(f"invalid GLB JSON chunk: {exc}") from exc
|
||||
|
||||
offset = chunk_end
|
||||
|
||||
if json_chunk is None:
|
||||
raise ValueError("GLB file does not contain a JSON chunk")
|
||||
|
||||
return json_chunk
|
||||
|
||||
|
||||
def _parse_gltf_json(content: bytes) -> dict[str, Any]:
|
||||
"""Parse a plain .gltf JSON file."""
|
||||
stripped = content.lstrip()
|
||||
try:
|
||||
payload = json.loads(stripped.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError(f"invalid glTF JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("glTF JSON root must be an object")
|
||||
return payload
|
||||
|
||||
|
||||
def _position_accessor_counts(gltf: dict[str, Any]) -> list[int]:
|
||||
"""Return POSITION accessor counts for every mesh primitive."""
|
||||
counts: list[int] = []
|
||||
accessors = gltf.get("accessors", [])
|
||||
for mesh in gltf.get("meshes", []):
|
||||
for primitive in mesh.get("primitives", []):
|
||||
attributes = primitive.get("attributes", {})
|
||||
position_index = attributes.get("POSITION")
|
||||
if position_index is None:
|
||||
continue
|
||||
try:
|
||||
accessor = accessors[int(position_index)]
|
||||
except (IndexError, TypeError, ValueError):
|
||||
continue
|
||||
count = accessor.get("count")
|
||||
if isinstance(count, int):
|
||||
counts.append(count)
|
||||
return counts
|
||||
|
||||
|
||||
def _position_bounds(gltf: dict[str, Any]) -> list[list[float]]:
|
||||
"""Return min/max bounds for every POSITION accessor that declares them."""
|
||||
bounds: list[list[float]] = []
|
||||
accessors = gltf.get("accessors", [])
|
||||
for mesh in gltf.get("meshes", []):
|
||||
for primitive in mesh.get("primitives", []):
|
||||
attributes = primitive.get("attributes", {})
|
||||
position_index = attributes.get("POSITION")
|
||||
if position_index is None:
|
||||
continue
|
||||
try:
|
||||
accessor = accessors[int(position_index)]
|
||||
except (IndexError, TypeError, ValueError):
|
||||
continue
|
||||
minimum = accessor.get("min")
|
||||
maximum = accessor.get("max")
|
||||
if isinstance(minimum, list) and isinstance(maximum, list):
|
||||
bounds.append([float(x) for x in minimum])
|
||||
bounds.append([float(x) for x in maximum])
|
||||
return bounds
|
||||
|
||||
|
||||
def _texture_refs(gltf: dict[str, Any]) -> list[str]:
|
||||
"""Collect external texture source references from the image array."""
|
||||
refs: list[str] = []
|
||||
for index, image in enumerate(gltf.get("images", [])):
|
||||
uri = image.get("uri")
|
||||
if isinstance(uri, str) and not uri.startswith("data:"):
|
||||
refs.append(uri)
|
||||
continue
|
||||
|
||||
name = image.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
refs.append(name)
|
||||
continue
|
||||
|
||||
if isinstance(uri, str):
|
||||
# Embedded data URI; keep a stable, non-huge reference.
|
||||
refs.append(f"data:{index}")
|
||||
continue
|
||||
|
||||
buffer_view = image.get("bufferView")
|
||||
if buffer_view is not None:
|
||||
refs.append(f"bufferView:{buffer_view}")
|
||||
continue
|
||||
|
||||
refs.append(f"image:{index}")
|
||||
|
||||
return refs
|
||||
|
||||
|
||||
def extract_gltf_metadata(content: bytes) -> dict[str, Any]:
|
||||
"""Extract upload-relevant metadata from glTF/GLB bytes.
|
||||
|
||||
Raises ValueError for malformed glTF content. The returned dict contains:
|
||||
mesh_count, vertex_count, has_animation, texture_refs,
|
||||
bounding_box_min, bounding_box_max.
|
||||
"""
|
||||
if content.startswith(_GLB_MAGIC):
|
||||
gltf = _parse_glb(content)
|
||||
else:
|
||||
gltf = _parse_gltf_json(content)
|
||||
|
||||
position_counts = _position_accessor_counts(gltf)
|
||||
bounds = _position_bounds(gltf)
|
||||
|
||||
if bounds:
|
||||
bounding_box_min = [min(axis) for axis in zip(*bounds[::2])]
|
||||
bounding_box_max = [max(axis) for axis in zip(*bounds[1::2])]
|
||||
else:
|
||||
bounding_box_min = [0.0, 0.0, 0.0]
|
||||
bounding_box_max = [0.0, 0.0, 0.0]
|
||||
|
||||
return {
|
||||
"mesh_count": len(gltf.get("meshes", [])),
|
||||
"vertex_count": sum(position_counts),
|
||||
"has_animation": bool(gltf.get("animations")),
|
||||
"texture_refs": _texture_refs(gltf),
|
||||
"bounding_box_min": bounding_box_min,
|
||||
"bounding_box_max": bounding_box_max,
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Tests for the lightweight glTF metadata extraction service."""
|
||||
|
||||
import json
|
||||
import struct
|
||||
import pytest
|
||||
|
||||
from app.services.gltf import (
|
||||
extract_gltf_metadata,
|
||||
looks_like_gltf,
|
||||
_parse_glb,
|
||||
_parse_gltf_json,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test data helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_gltf_json(
|
||||
meshes: int = 1,
|
||||
vertices_per_mesh: int = 24,
|
||||
has_animations: bool = False,
|
||||
images: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Build a minimal glTF JSON structure."""
|
||||
accessors = []
|
||||
meshes_data = []
|
||||
for i in range(meshes):
|
||||
acc_index = len(accessors)
|
||||
accessors.append({
|
||||
"bufferView": 0,
|
||||
"componentType": 5126, # FLOAT
|
||||
"count": vertices_per_mesh,
|
||||
"type": "VEC3",
|
||||
"max": [1.0, 1.0, 1.0],
|
||||
"min": [-1.0, -1.0, -1.0],
|
||||
})
|
||||
meshes_data.append({
|
||||
"primitives": [{
|
||||
"attributes": {"POSITION": acc_index},
|
||||
}],
|
||||
})
|
||||
gltf = {
|
||||
"asset": {"version": "2.0"},
|
||||
"accessors": accessors,
|
||||
"meshes": meshes_data,
|
||||
}
|
||||
if has_animations:
|
||||
gltf["animations"] = [{"name": "anim1", "channels": [], "samplers": []}]
|
||||
if images:
|
||||
gltf["images"] = images
|
||||
return gltf
|
||||
|
||||
|
||||
def _make_glb(gltf_json: dict) -> bytes:
|
||||
"""Wrap *gltf_json* in a minimal GLB binary container."""
|
||||
json_bytes = json.dumps(gltf_json).encode("utf-8")
|
||||
json_length = len(json_bytes)
|
||||
# Pad JSON chunk to 4-byte boundary.
|
||||
json_padding = (4 - json_length % 4) % 4
|
||||
json_chunk = json_bytes + b"\x00" * json_padding
|
||||
json_chunk_type = 0x4E4F534A # "JSON"
|
||||
# No BIN chunk.
|
||||
bin_length = 0
|
||||
bin_chunk_type = 0x004E4942
|
||||
total_length = 12 + 8 + len(json_chunk) + 8 + bin_length
|
||||
header = struct.pack("<III", 0x46546C67, 2, total_length) # magic, version, length
|
||||
json_header = struct.pack("<II", len(json_chunk), json_chunk_type)
|
||||
if bin_length > 0:
|
||||
bin_header = struct.pack("<II", bin_length, bin_chunk_type)
|
||||
else:
|
||||
bin_header = b""
|
||||
return header + json_header + json_chunk + bin_header
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# looks_like_gltf
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLooksLikeGltf:
|
||||
def test_glb_magic(self):
|
||||
assert looks_like_gltf(b"glTF\x00\x00\x00\x00") is True
|
||||
|
||||
def test_gltf_extension(self):
|
||||
assert looks_like_gltf(b'{"asset":{"version":"2.0"}}', "model.gltf") is True
|
||||
|
||||
def test_glb_extension(self):
|
||||
assert looks_like_gltf(b"some binary", "model.glb") is True
|
||||
|
||||
def test_json_with_asset_version(self):
|
||||
payload = json.dumps({"asset": {"version": "2.0"}}).encode()
|
||||
assert looks_like_gltf(payload) is True
|
||||
|
||||
def test_plain_json_no_asset(self):
|
||||
payload = json.dumps({"foo": "bar"}).encode()
|
||||
assert looks_like_gltf(payload) is False
|
||||
|
||||
def test_non_json_bytes(self):
|
||||
assert looks_like_gltf(b"\x89PNG\r\n\x1a\n") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_glb
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseGlb:
|
||||
def test_parses_valid_glb(self):
|
||||
gltf_json = _make_gltf_json()
|
||||
glb = _make_glb(gltf_json)
|
||||
result = _parse_glb(glb)
|
||||
assert result["asset"]["version"] == "2.0"
|
||||
assert len(result["meshes"]) == 1
|
||||
|
||||
def test_rejects_too_short(self):
|
||||
with pytest.raises(ValueError, match="too short"):
|
||||
_parse_glb(b"glTF\x00\x00")
|
||||
|
||||
def test_rejects_invalid_magic(self):
|
||||
with pytest.raises(ValueError, match="invalid GLB magic"):
|
||||
_parse_glb(b"NOTglTF\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_gltf_json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseGltfJson:
|
||||
def test_parses_valid_gltf(self):
|
||||
gltf_json = _make_gltf_json()
|
||||
result = _parse_gltf_json(json.dumps(gltf_json).encode())
|
||||
assert result["asset"]["version"] == "2.0"
|
||||
|
||||
def test_rejects_non_object(self):
|
||||
with pytest.raises(ValueError, match="root must be an object"):
|
||||
_parse_gltf_json(b"[1, 2, 3]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_gltf_metadata (GLB path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGltfMetadataGlb:
|
||||
def test_basic_glb(self):
|
||||
gltf_json = _make_gltf_json(meshes=1, vertices_per_mesh=24)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["mesh_count"] == 1
|
||||
assert meta["vertex_count"] == 24
|
||||
assert meta["has_animation"] is False
|
||||
assert meta["bounding_box_min"] == [-1.0, -1.0, -1.0]
|
||||
assert meta["bounding_box_max"] == [1.0, 1.0, 1.0]
|
||||
assert meta["texture_refs"] == []
|
||||
|
||||
def test_multiple_meshes(self):
|
||||
gltf_json = _make_gltf_json(meshes=3, vertices_per_mesh=12)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["mesh_count"] == 3
|
||||
assert meta["vertex_count"] == 36
|
||||
|
||||
def test_with_animation(self):
|
||||
gltf_json = _make_gltf_json(has_animations=True)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["has_animation"] is True
|
||||
|
||||
def test_with_external_texture(self):
|
||||
gltf_json = _make_gltf_json(images=[{"uri": "textures/rock.png"}])
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["texture_refs"] == ["textures/rock.png"]
|
||||
|
||||
def test_with_named_texture(self):
|
||||
gltf_json = _make_gltf_json(images=[{"name": "my_texture"}])
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["texture_refs"] == ["my_texture"]
|
||||
|
||||
def test_empty_meshes(self):
|
||||
gltf_json = _make_gltf_json(meshes=0)
|
||||
glb = _make_glb(gltf_json)
|
||||
meta = extract_gltf_metadata(glb)
|
||||
assert meta["mesh_count"] == 0
|
||||
assert meta["vertex_count"] == 0
|
||||
assert meta["bounding_box_min"] == [0.0, 0.0, 0.0]
|
||||
assert meta["bounding_box_max"] == [0.0, 0.0, 0.0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_gltf_metadata (plain .gltf JSON path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGltfMetadataGltfJson:
|
||||
def test_plain_gltf(self):
|
||||
gltf_json = _make_gltf_json(meshes=2, vertices_per_mesh=8)
|
||||
raw = json.dumps(gltf_json).encode()
|
||||
meta = extract_gltf_metadata(raw)
|
||||
assert meta["mesh_count"] == 2
|
||||
assert meta["vertex_count"] == 16
|
||||
assert meta["has_animation"] is False
|
||||
|
||||
def test_plain_gltf_with_animation(self):
|
||||
gltf_json = _make_gltf_json(has_animations=True)
|
||||
raw = json.dumps(gltf_json).encode()
|
||||
meta = extract_gltf_metadata(raw)
|
||||
assert meta["has_animation"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGltfMetadataErrors:
|
||||
def test_invalid_glb(self):
|
||||
with pytest.raises(ValueError):
|
||||
extract_gltf_metadata(b"not a glb")
|
||||
|
||||
def test_invalid_gltf_json(self):
|
||||
with pytest.raises(ValueError):
|
||||
_parse_gltf_json(b"[1, 2, 3]")
|
||||
@@ -1,182 +0,0 @@
|
||||
# Creator Studio — Extended Authoring Tools
|
||||
|
||||
> **Feature:** Creator Studio — Extended Authoring Tools
|
||||
> **Status:** Planning
|
||||
> **Owner:** Platform team
|
||||
|
||||
## Feature Overview
|
||||
|
||||
Extends the in-game world editor (M6) toward a real authoring tool with:
|
||||
1. **Asset import pipeline** (glTF models/textures)
|
||||
2. **Script editor** with sandboxed API surface exposed clearly
|
||||
3. **Experience publishing flow** (draft → review → live)
|
||||
4. **Basic analytics** for creators (plays, session length)
|
||||
|
||||
## User Stories
|
||||
|
||||
### US-1: Asset Import Pipeline
|
||||
_As a creator, I can import glTF models and textures into my experience so that I can use custom 3D assets._
|
||||
|
||||
### US-2: Script Editor with Sandboxed API
|
||||
_As a creator, I can write and test scripts in the editor with autocomplete/docs for the available API surface._
|
||||
|
||||
### US-3: Experience Publishing Flow
|
||||
_As a creator, I can publish my experience through a draft → review → live pipeline so that players can discover and play it._
|
||||
|
||||
### US-4: Creator Analytics
|
||||
_As a creator, I can see how many plays and average session length my experiences receive so that I can understand player engagement._
|
||||
|
||||
---
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### US-1: Asset Import Pipeline
|
||||
|
||||
#### T1.1: Backend — glTF Asset Upload with Metadata Extraction
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/assets.py` (extend upload endpoint), `backend/app/models/asset.py` (add glTF-specific metadata fields)
|
||||
- **Scope:** Extend the existing `upload_asset_file` endpoint in `assets.py` to detect `.glb`/`.gltf` files and extract glTF metadata (mesh_count, vertex_count, has_animation, texture_refs, bounding_box) from the embedded JSON. Store extracted metadata in `metadata_json`. Add a `has_gltf_metadata` computed property. The Asset model already has `asset_type = MODEL_3D` and `metadata_json` — this task adds the extraction logic.
|
||||
- **Acceptance:** POST `/api/assets/{id}/upload` with a `.glb` file returns asset with `metadata_json` containing `mesh_count`, `vertex_count`, `has_animation`, `texture_refs`. GET lists include these fields. File stored on disk. Non-glTF files are unaffected.
|
||||
- **Dependencies:** None (builds on existing asset infrastructure)
|
||||
|
||||
#### T1.2: Backend — Public Asset Catalog Endpoint
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/assets.py` (new endpoint)
|
||||
- **Scope:** Add `GET /api/assets/catalog` endpoint that returns all public glTF assets with their metadata (name, thumbnail_url, mesh_count, vertex_count, tags). Add filtering by tags and pagination. This is what the editor's asset palette queries. Uses the existing `Asset.is_public` field.
|
||||
- **Acceptance:** Catalog endpoint returns paginated list of public assets; filtering by tag works; pagination params respected; returns 401 without auth.
|
||||
- **Dependencies:** T1.1
|
||||
|
||||
#### T1.3: Godot — glTF Import Utility (Editor-Side)
|
||||
- **Type:** implementation
|
||||
- **Files:** `scripts/gltf_importer.gd` (new), `scripts/asset_catalog.gd` (new)
|
||||
- **Scope:** Godot-side utility that connects to the catalog API, downloads glTF files, and converts them to Godot-compatible resources (`.res` files with embedded mesh data). Provides `import_gltf(url: String) -> Resource` function. Handles texture extraction and mesh merging. Uses the ApiClient autoload (from client-server-wiring plan) for HTTP.
|
||||
- **Acceptance:** `import_gltf()` downloads a glTF from the catalog, converts mesh to `Mesh` resource, returns a ready-to-use Godot resource. Texture files stored in `user://assets/`.
|
||||
- **Dependencies:** T1.2, client-server-wiring ApiClient (task 1 from that plan)
|
||||
|
||||
### US-2: Script Editor with Sandboxed API
|
||||
|
||||
> **Design note from `scripting_sandbox.md`:** Godot 4 has no built-in GDScript sandbox. The recommended approach is a **data-driven DSL** (JSON/Resource) interpreted by trusted GDScript, not arbitrary code execution. This task follows that recommendation.
|
||||
|
||||
#### T2.1: Backend — Script Model & CRUD Endpoints
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/script.py` (new), `backend/app/routers/scripts.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `Script` model (id, name, world_id, owner_id, source_code TEXT, version INT, script_type ENUM[gdscript, dsl], dsl_payload JSON, created_at, updated_at). Add full CRUD endpoints: `POST /api/scripts/` (create), `GET /api/scripts/` (list), `GET /api/scripts/{id}` (get), `PUT /api/scripts/{id}` (update), `DELETE /api/scripts/{id}` (delete). Scripts are owned by users and associated with a world. `dsl_payload` stores the JSON/Resource data for the DSL approach; `source_code` stores raw GDScript if used.
|
||||
- **Acceptance:** All CRUD endpoints work; scripts are owned by creator; list endpoint filters by owner/world; update increments version; DSL and gdscript types supported.
|
||||
- **Dependencies:** None (new model + router)
|
||||
|
||||
#### T2.2: Godot — Script API Surface Definition
|
||||
- **Type:** architecture
|
||||
- **Files:** `scripts/editor_script_api.gd` (new)
|
||||
- **Scope:** Define the public API surface available to creator scripts. This is a `RefCounted` class that wraps the Godot engine's capabilities into a safe, documented interface. Includes: `WorldAPI` (get_tile, get_object, set_property), `PlayerAPI` (get_position, get_health), `AudioAPI` (play_sound, play_music), `NetworkAPI` (send_event, broadcast). Each method has documented parameters and return types. Self-documenting via `##` doc comments for autocomplete.
|
||||
- **Acceptance:** `EditorScriptAPI` class exposes all documented methods; methods are callable from user scripts; API surface is self-documenting.
|
||||
- **Dependencies:** None (standalone definition)
|
||||
|
||||
#### T2.3: Godot — In-Game Script Editor UI
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/editor_script_editor.tscn` (new), `scripts/editor_script_editor.gd` (new)
|
||||
- **Scope:** A `Control`-based UI panel that provides a script editor within the game. Features: code editor with syntax highlighting (basic), autocomplete dropdown, run button (calls backend `/run` endpoint), output console (shows stdout/stderr), API reference panel (shows available methods). Integrates with the existing editor toolbar.
|
||||
- **Acceptance:** Editor panel opens from toolbar; code editor accepts input; autocomplete shows API methods; run button executes script and shows output; API reference panel is browsable.
|
||||
- **Dependencies:** T2.1, T2.2
|
||||
|
||||
### US-3: Experience Publishing Flow
|
||||
|
||||
#### T3.1: Backend — Experience Model & Status Transitions
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/experience.py` (new), `backend/app/routers/experiences.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `Experience` model (id, world_id FK→worlds, title, description, status ENUM[DRAFT,SUBMITTED,IN_REVIEW,APPROVED,REJECTED,LIVE], published_at, review_notes, reviewer_id FK→users). Status transitions enforced: DRAFT→SUBMITTED→IN_REVIEW→APPROVED→LIVE; REJECTED→DRAFT (resubmit). Add endpoints: `POST /api/experiences/` (create draft), `POST /api/experiences/{id}/submit` (submit for review), `POST /api/experiences/{id}/approve` (approve), `POST /api/experiences/{id}/reject` (reject), `GET /api/experiences/` (list with status filter). An experience is a **publishing wrapper** around a World — the world's visibility is set to PUBLIC when experience goes LIVE.
|
||||
- **Acceptance:** All CRUD + transition endpoints work; status transitions enforced; rejected can be resubmitted; list filters by status; approve sets world visibility to PUBLIC; only world owner can submit; only moderators can approve/reject.
|
||||
- **Dependencies:** None (new model + router)
|
||||
|
||||
#### T3.2: Backend — Review Queue & Moderation Integration
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/experiences.py` (add endpoints)
|
||||
- **Scope:** Add review queue endpoint `GET /api/experiences/review-queue` (returns experiences in IN_REVIEW status). Add `POST /api/experiences/{id}/review` endpoint for moderators to add review notes and change status. Integrates with content moderation: when an experience is submitted, its world's content flags are computed and attached to the review.
|
||||
- **Acceptance:** Review queue returns IN_REVIEW experiences; review endpoint accepts notes and status change; content flags attached on submit; only moderators can review.
|
||||
- **Dependencies:** T3.1
|
||||
|
||||
#### T3.3: Godot — Publishing UI Panel
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/editor_publish_panel.tscn` (new), `scripts/editor_publish_panel.gd` (new)
|
||||
- **Scope:** A `Control`-based panel in the editor that lets creators manage their experience's publishing state. Features: title/description editor, status badge (DRAFT/SUBMITTED/LIVE), submit button, review notes display, publish history timeline. Integrates with the existing editor toolbar.
|
||||
- **Acceptance:** Panel shows current status; submit button calls backend; review notes displayed; publish history shown; status badge updates on refresh.
|
||||
- **Dependencies:** T3.1, T3.2
|
||||
|
||||
#### T3.4: Godot — Experience List & Discovery
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/experience_list.tscn` (new), `scripts/experience_list.gd` (new)
|
||||
- **Scope:** A UI panel that lists all experiences available to the player (LIVE status). Features: search/filter, experience cards (title, description, creator, play count), join button. Queries `GET /api/experiences/?status=LIVE` and displays results.
|
||||
- **Acceptance:** Lists LIVE experiences; search filters by title/description; cards show metadata; join button loads the experience's world.
|
||||
- **Dependencies:** T3.1
|
||||
|
||||
### US-4: Creator Analytics
|
||||
|
||||
#### T4.1: Backend — Session & Play Tracking Models
|
||||
- **Type:** architecture
|
||||
- **Files:** `backend/app/models/analytics.py` (new), `backend/app/routers/analytics.py` (new), `backend/app/main.py` (register router), `backend/app/models/__init__.py` (export)
|
||||
- **Scope:** Create `PlaySession` model (id, experience_id FK→experiences, player_id FK→users, started_at, ended_at, duration_seconds, chunk_loads INT, interactions_count INT). Create `PlayEvent` model (id, experience_id FK→experiences, player_id FK→users, event_type, payload JSON, created_at). Add endpoints: `POST /api/analytics/session/start` (start session), `POST /api/analytics/session/end` (end session), `POST /api/analytics/event` (log event). Note: `sessions` table is taken by auth tokens; this uses `play_sessions` table name.
|
||||
- **Acceptance:** Session start/end endpoints work; events logged; duration calculated correctly; endpoints accept valid experience/player IDs.
|
||||
- **Dependencies:** None (new models + routers)
|
||||
|
||||
#### T4.2: Backend — Creator Dashboard Analytics Endpoint
|
||||
- **Type:** implementation
|
||||
- **Files:** `backend/app/routers/analytics.py` (add endpoint)
|
||||
- **Scope:** Add `GET /api/analytics/experience/{id}` endpoint that returns aggregated analytics for an experience: total_plays, unique_players, avg_session_length, total_play_time, plays_over_time (daily breakdown), top_chunks_loaded, top_interactions. Data is aggregated from PlaySession and PlayEvent tables.
|
||||
- **Acceptance:** Returns all aggregated metrics; daily breakdown covers last 30 days; only owner or moderator can view; empty experience returns zeros.
|
||||
- **Dependencies:** T4.1
|
||||
|
||||
#### T4.3: Godot — Analytics Tracking in Game
|
||||
- **Type:** implementation
|
||||
- **Files:** `scripts/analytics_tracker.gd` (new)
|
||||
- **Scope:** A singleton-like service that tracks player sessions and events during gameplay. Automatically starts session on world load, tracks chunk loads, interactions, and time spent. Sends periodic heartbeat events to backend. Handles session end on world unload or disconnect.
|
||||
- **Acceptance:** Session starts on world load; events logged during play; heartbeat sends every 60s; session ends on world unload; handles disconnect gracefully.
|
||||
- **Dependencies:** T4.1
|
||||
|
||||
#### T4.4: Godot — Creator Analytics Dashboard UI
|
||||
- **Type:** implementation
|
||||
- **Files:** `scenes/creator_analytics_panel.tscn` (new), `scripts/creator_analytics_panel.gd` (new)
|
||||
- **Scope:** A `Control`-based panel in the editor that displays analytics for the current experience. Features: total plays counter, avg session length, plays over time chart (simple bar chart), top chunks, top interactions. Queries `GET /api/analytics/experience/{id}` and renders data.
|
||||
- **Acceptance:** Panel shows all metrics; chart renders bar chart of daily plays; data refreshes on button press; handles empty data gracefully.
|
||||
- **Dependencies:** T4.2, T4.3
|
||||
|
||||
---
|
||||
|
||||
## Database Migration Plan
|
||||
|
||||
New tables needed (in order):
|
||||
1. `scripts` — script storage
|
||||
2. `experiences` — publishing state (wraps worlds)
|
||||
3. `play_sessions` — session tracking (note: `sessions` table already exists for auth tokens)
|
||||
4. `play_events` — granular events
|
||||
|
||||
Migration file: `backend/alembic/versions/0003_creator_studio.py`
|
||||
|
||||
---
|
||||
|
||||
## Task Dependencies Summary
|
||||
|
||||
```
|
||||
T1.1 (glTF upload) ──→ T1.2 (catalog API) ──→ T1.3 (Godot importer)
|
||||
T2.1 (script model) ──→ T2.3 (editor UI)
|
||||
T2.2 (API surface) ──→ T2.3 (editor UI)
|
||||
T3.1 (experience model) ──→ T3.2 (review queue) ──→ T3.3 (publish UI)
|
||||
T3.1 ──→ T3.4 (discovery list)
|
||||
T4.1 (tracking model) ──→ T4.2 (dashboard endpoint) ──→ T4.4 (analytics UI)
|
||||
T4.1 ──→ T4.3 (game tracker) ──→ T4.4 (analytics UI)
|
||||
```
|
||||
|
||||
## Implementation Order Recommendation
|
||||
|
||||
1. **T1.1** — Foundation: asset upload with glTF metadata extraction
|
||||
2. **T2.1** — Foundation: script model + CRUD
|
||||
3. **T3.1** — Foundation: experience model + status transitions
|
||||
4. **T4.1** — Foundation: analytics tracking model
|
||||
5. **T1.2** — Asset catalog for editor
|
||||
6. **T2.2** — Script API surface definition
|
||||
7. **T3.2** — Review queue integration
|
||||
8. **T4.2** — Analytics dashboard endpoint
|
||||
9. **T1.3** — Godot glTF importer (depends on ApiClient from client-server-wiring)
|
||||
10. **T2.3** — Script editor UI
|
||||
11. **T3.3** — Publishing UI panel
|
||||
12. **T3.4** — Experience discovery list
|
||||
13. **T4.3** — Analytics tracker in game
|
||||
14. **T4.4** — Analytics dashboard UI
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjd0uhll7iylj
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgnc3iamfgf6k
|
||||
Reference in New Issue
Block a user