"""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)