af85048deb
New avatar_loadouts table (one row per user, loadout stored as a single JSONB document matching the client's AvatarLoadout.to_json() shape) and GET/PUT /api/avatar/loadout. The server has no copy of the asset catalog, so it validates only the envelope (JSON object, non-empty base_body, object slots, size cap) -- which-asset-in-which-slot validation stays client-side. New accounts get a server default (base_human_male_01, no slots) with is_default:true so the client can tell "never customised" from "customised back to base". - models/avatar_loadout.py + 0004 migration + User.avatar_loadout relation - routers/avatar.py wired into main.py - tests/test_avatar_loadout.py (7 tests: default, save/read, replace, validation, auth) -- full suite 68 passed - lifted the duplicated _override_db fixture from 3 test files into conftest.py (new test files need it too) - docs/architecture/avatar.md §9a: the persistence design Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
79 lines
2.6 KiB
Python
79 lines
2.6 KiB
Python
"""Avatar router - the current user's persisted avatar loadout.
|
|
|
|
The loadout is a single JSON document matching the client's
|
|
``AvatarLoadout.to_json()`` shape. The server has no copy of the asset
|
|
catalog, so it only validates the envelope (JSON object, non-empty
|
|
``base_body``); which assets are valid for which slot is the client's job.
|
|
"""
|
|
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.dependencies import CurrentUser
|
|
from app.models.avatar_loadout import AvatarLoadout, DEFAULT_LOADOUT
|
|
|
|
router = APIRouter()
|
|
|
|
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
|
|
|
# A loadout document is small; reject anything absurd outright.
|
|
MAX_LOADOUT_BYTES = 16 * 1024
|
|
|
|
|
|
class LoadoutResponse(BaseModel):
|
|
data: dict[str, Any]
|
|
is_default: bool = Field(
|
|
description="True when the user has never saved a loadout and this is "
|
|
"the server default."
|
|
)
|
|
|
|
|
|
class LoadoutUpdate(BaseModel):
|
|
data: dict[str, Any]
|
|
|
|
|
|
def _validate_envelope(data: dict[str, Any]) -> None:
|
|
base_body = data.get("base_body")
|
|
if not isinstance(base_body, str) or not base_body.strip():
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="loadout.data must have a non-empty string 'base_body'",
|
|
)
|
|
slots = data.get("slots", {})
|
|
if not isinstance(slots, dict):
|
|
raise HTTPException(status_code=422, detail="loadout.data.slots must be an object")
|
|
if len(str(data)) > MAX_LOADOUT_BYTES:
|
|
raise HTTPException(status_code=413, detail="loadout document too large")
|
|
|
|
|
|
@router.get("/loadout", response_model=LoadoutResponse)
|
|
async def get_loadout(current_user: CurrentUser, db: DbDep):
|
|
"""Return the current user's saved loadout, or the server default if they
|
|
have never saved one."""
|
|
row = await db.get(AvatarLoadout, current_user.id)
|
|
if row is None:
|
|
return LoadoutResponse(data=dict(DEFAULT_LOADOUT), is_default=True)
|
|
return LoadoutResponse(data=row.data, is_default=False)
|
|
|
|
|
|
@router.put("/loadout", response_model=LoadoutResponse)
|
|
async def put_loadout(payload: LoadoutUpdate, current_user: CurrentUser, db: DbDep):
|
|
"""Replace the current user's loadout."""
|
|
_validate_envelope(payload.data)
|
|
|
|
row = await db.get(AvatarLoadout, current_user.id)
|
|
if row is None:
|
|
row = AvatarLoadout(user_id=current_user.id, data=payload.data)
|
|
db.add(row)
|
|
else:
|
|
row.data = payload.data
|
|
|
|
await db.commit()
|
|
await db.refresh(row)
|
|
return LoadoutResponse(data=row.data, is_default=False)
|