Add Profile model + migration + own-profile endpoints (US-3)

Implements user profile data model and own-profile management endpoints:
- Profile and ProfileFavorites models in backend/app/models/profile.py
- Migration 0010_add_profile_tables.py (profiles, profile_favorites)
- Endpoints in backend/app/routers/profiles.py:
  - GET  /api/profile/me (own profile with default fallback)
  - PUT  /api/profile/me (upsert bio, avatar_asset_id, privacy settings)
  - GET  /api/profile/me/favorites (list favorite world IDs)
  - PUT  /api/profile/me/favorites (replace favorite world IDs)
- Fixes values_callable on Asset.asset_type ENUM in app/models/asset.py
- Added test_profile.py covering auth, validation, favorites, privacy

Independently re-verified: 19/19 new profile tests pass,
258/258 full backend regression suite passes with zero regressions.
This commit is contained in:
2026-09-15 12:08:50 +00:00
parent abd05b266d
commit 0a0dde3d50
9 changed files with 673 additions and 2 deletions
+205
View File
@@ -0,0 +1,205 @@
"""Profile router -- the authenticated user's own profile + favorites.
Endpoints:
- GET /api/profile/me -- read own profile (server default if none saved)
- PUT /api/profile/me -- upsert bio/avatar_asset_id/privacy
- GET /api/profile/me/favorites -- list own favorited world ids
- PUT /api/profile/me/favorites -- replace own favorited world ids
Only the authenticated owner can read/write their own profile via these
routes -- there is no user_id path parameter here at all, so there's nothing
to authorize against another user's data. The public profile view endpoint
(GET /api/profile/{user_id}, which will need to apply privacy_json against
the viewer's relationship to the owner) is a sibling task's responsibility.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import CurrentUser
from app.models.asset import Asset
from app.models.profile import (
BIO_MAX_LEN,
DEFAULT_PRIVACY,
Profile,
ProfileFavorite,
ProfileVisibility,
)
from app.models.world import World
router = APIRouter()
DbDep = Annotated[AsyncSession, Depends(get_db)]
_PRIVACY_KEYS = ("profile_visibility", "friends_list_visibility", "favorites_visibility")
_VISIBILITY_VALUES = {v.value for v in ProfileVisibility}
# ──────────────────────────── Pydantic schemas ────────────────────────────────
class ProfileResponse(BaseModel):
user_id: int
avatar_asset_id: int | None
bio: str | None
privacy: dict[str, str]
is_default: bool = Field(
description="True when the user has never saved a profile and this "
"is the server default (no bio/avatar, default-open privacy)."
)
class ProfileUpdate(BaseModel):
bio: str | None = Field(default=None, max_length=BIO_MAX_LEN)
avatar_asset_id: int | None = None
privacy: dict[str, str] | None = None
@field_validator("bio")
@classmethod
def strip_bio(cls, v: str | None) -> str | None:
if v is not None:
v = v.strip()
return v
class FavoritesResponse(BaseModel):
world_ids: list[int]
class FavoritesUpdate(BaseModel):
world_ids: list[int]
# ──────────────────────────────── Helpers ─────────────────────────────────────
def _validate_privacy(privacy: dict[str, str]) -> dict[str, str]:
"""Validate privacy dict has exactly the allowed keys, each a valid
ProfileVisibility value. Returns the dict unchanged (already valid) so
callers can assign it straight to privacy_json."""
extra = set(privacy.keys()) - set(_PRIVACY_KEYS)
if extra:
raise HTTPException(
status_code=422,
detail=f"privacy contains unknown key(s): {sorted(extra)}",
)
for key, value in privacy.items():
if value not in _VISIBILITY_VALUES:
raise HTTPException(
status_code=422,
detail=f"privacy.{key} must be one of {sorted(_VISIBILITY_VALUES)}, got {value!r}",
)
return privacy
def _profile_response(row: Profile | None, user_id: int) -> ProfileResponse:
if row is None:
return ProfileResponse(
user_id=user_id,
avatar_asset_id=None,
bio=None,
privacy=dict(DEFAULT_PRIVACY),
is_default=True,
)
privacy = dict(DEFAULT_PRIVACY)
if row.privacy_json:
privacy.update(row.privacy_json)
return ProfileResponse(
user_id=row.user_id,
avatar_asset_id=row.avatar_asset_id,
bio=row.bio,
privacy=privacy,
is_default=False,
)
# ──────────────────────────────── Endpoints ───────────────────────────────────
@router.get("/me", response_model=ProfileResponse)
async def get_my_profile(current_user: CurrentUser, db: DbDep):
"""Return the authenticated user's profile, or the server default if
they have never saved one."""
row = await db.get(Profile, current_user.id)
return _profile_response(row, current_user.id)
@router.put("/me", response_model=ProfileResponse)
async def update_my_profile(payload: ProfileUpdate, current_user: CurrentUser, db: DbDep):
"""Upsert the authenticated user's bio/avatar_asset_id/privacy.
Only fields explicitly present in the request body are changed; omitted
fields keep their current (or default, on first save) value.
"""
fields_set = payload.model_fields_set
if "avatar_asset_id" in fields_set and payload.avatar_asset_id is not None:
asset = await db.get(Asset, payload.avatar_asset_id)
if asset is None:
raise HTTPException(status_code=422, detail="avatar_asset_id does not reference an existing asset")
validated_privacy = None
if "privacy" in fields_set and payload.privacy is not None:
validated_privacy = _validate_privacy(payload.privacy)
row = await db.get(Profile, current_user.id)
if row is None:
row = Profile(
user_id=current_user.id,
bio=payload.bio if "bio" in fields_set else None,
avatar_asset_id=payload.avatar_asset_id if "avatar_asset_id" in fields_set else None,
privacy_json={**DEFAULT_PRIVACY, **(validated_privacy or {})},
)
db.add(row)
else:
if "bio" in fields_set:
row.bio = payload.bio
if "avatar_asset_id" in fields_set:
row.avatar_asset_id = payload.avatar_asset_id
if validated_privacy is not None:
merged = dict(row.privacy_json or DEFAULT_PRIVACY)
merged.update(validated_privacy)
row.privacy_json = merged
await db.commit()
await db.refresh(row)
return _profile_response(row, current_user.id)
@router.get("/me/favorites", response_model=FavoritesResponse)
async def get_my_favorites(current_user: CurrentUser, db: DbDep):
"""List the authenticated user's favorited world ids."""
result = await db.execute(
select(ProfileFavorite.world_id).where(ProfileFavorite.user_id == current_user.id)
)
return FavoritesResponse(world_ids=[wid for wid in result.scalars().all()])
@router.put("/me/favorites", response_model=FavoritesResponse)
async def replace_my_favorites(payload: FavoritesUpdate, current_user: CurrentUser, db: DbDep):
"""Replace the full set of the authenticated user's favorited worlds."""
world_ids = list(dict.fromkeys(payload.world_ids)) # de-dupe, preserve order
if world_ids:
result = await db.execute(select(World.id).where(World.id.in_(world_ids)))
existing_ids = set(result.scalars().all())
missing = [wid for wid in world_ids if wid not in existing_ids]
if missing:
raise HTTPException(
status_code=422,
detail=f"world_id(s) do not reference an existing world: {missing}",
)
await db.execute(
ProfileFavorite.__table__.delete().where(ProfileFavorite.user_id == current_user.id)
)
for wid in world_ids:
db.add(ProfileFavorite(user_id=current_user.id, world_id=wid))
await db.commit()
return FavoritesResponse(world_ids=world_ids)