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:
@@ -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)
|
||||
Reference in New Issue
Block a user