"""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, or_, and_ 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.social import Friendship, Block, FriendshipStatus from app.models.user import User 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 PublicProfileWorld(BaseModel): id: int name: str model_config = {"from_attributes": True} class PublicProfileUser(BaseModel): id: int username: str model_config = {"from_attributes": True} class PublicProfileResponse(BaseModel): user_id: int username: str avatar_asset_id: int | None = None bio: str | None = None favorites: list[PublicProfileWorld] = Field(default_factory=list) friends: list[PublicProfileUser] = Field(default_factory=list) is_restricted: bool = False 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) @router.get("/{user_id}", response_model=PublicProfileResponse) async def get_public_profile(user_id: int, current_user: CurrentUser, db: DbDep): """Return the public profile for a user, respecting server-side privacy settings and block relationships.""" user_res = await db.execute(select(User).where(User.id == user_id)) target_user = user_res.scalar_one_or_none() if target_user is None or not target_user.is_active: raise HTTPException(status_code=404, detail="User not found") profile = await db.get(Profile, user_id) avatar_asset = None if profile and profile.avatar_asset_id: avatar_asset = await db.get(Asset, profile.avatar_asset_id) privacy = dict(DEFAULT_PRIVACY) if profile and profile.privacy_json: privacy.update(profile.privacy_json) is_self = current_user.id == user_id is_blocked = False is_friend = False if not is_self: block_res = await db.execute( select(Block.id).where( or_( and_(Block.blocker_id == current_user.id, Block.blocked_id == user_id), and_(Block.blocker_id == user_id, Block.blocked_id == current_user.id), ) ) ) is_blocked = block_res.scalar_one_or_none() is not None if not is_blocked: friend_res = await db.execute( select(Friendship.id).where( or_( and_(Friendship.requester_id == current_user.id, Friendship.addressee_id == user_id), and_(Friendship.requester_id == user_id, Friendship.addressee_id == current_user.id), ), Friendship.status == FriendshipStatus.ACCEPTED, ) ) is_friend = friend_res.scalar_one_or_none() is not None profile_vis = privacy.get("profile_visibility", ProfileVisibility.PUBLIC.value) if profile_vis is False: profile_vis = ProfileVisibility.PRIVATE.value elif profile_vis is True: profile_vis = ProfileVisibility.PUBLIC.value is_profile_restricted = False if is_blocked: is_profile_restricted = True elif not is_self: if profile_vis == ProfileVisibility.PRIVATE.value: is_profile_restricted = True elif profile_vis == ProfileVisibility.FRIENDS.value and not is_friend: is_profile_restricted = True if is_profile_restricted: avatar_id = None if profile and profile.avatar_asset_id: if avatar_asset and avatar_asset.is_public: avatar_id = profile.avatar_asset_id return PublicProfileResponse( user_id=target_user.id, username=target_user.username, avatar_asset_id=avatar_id, bio=None, favorites=[], friends=[], is_restricted=True, ) avatar_id = profile.avatar_asset_id if profile else None bio = profile.bio if profile else None fav_vis = privacy.get("favorites_visibility", ProfileVisibility.PUBLIC.value) if fav_vis is False: fav_vis = ProfileVisibility.PRIVATE.value elif fav_vis is True: fav_vis = ProfileVisibility.PUBLIC.value can_see_favorites = is_self or (fav_vis == ProfileVisibility.PUBLIC.value) or (fav_vis == ProfileVisibility.FRIENDS.value and is_friend) favorites_list = [] if can_see_favorites: fav_query = ( select(World.id, World.name) .join(ProfileFavorite, ProfileFavorite.world_id == World.id) .where(ProfileFavorite.user_id == user_id) .order_by(ProfileFavorite.created_at.desc()) ) fav_res = await db.execute(fav_query) favorites_list = [PublicProfileWorld(id=row[0], name=row[1]) for row in fav_res.all()] fr_vis = privacy.get("friends_list_visibility", ProfileVisibility.PUBLIC.value) if fr_vis is False: fr_vis = ProfileVisibility.PRIVATE.value elif fr_vis is True: fr_vis = ProfileVisibility.PUBLIC.value can_see_friends = is_self or (fr_vis == ProfileVisibility.PUBLIC.value) or (fr_vis == ProfileVisibility.FRIENDS.value and is_friend) friends_list = [] if can_see_friends: fr_res = await db.execute( select(Friendship).where( or_(Friendship.requester_id == user_id, Friendship.addressee_id == user_id), Friendship.status == FriendshipStatus.ACCEPTED, ) ) friendships = fr_res.scalars().all() other_ids = [ fr.addressee_id if fr.requester_id == user_id else fr.requester_id for fr in friendships ] if other_ids: users_res = await db.execute( select(User.id, User.username).where(User.id.in_(other_ids), User.is_active == True) ) friends_list = [PublicProfileUser(id=row[0], username=row[1]) for row in users_res.all()] return PublicProfileResponse( user_id=target_user.id, username=target_user.username, avatar_asset_id=avatar_id, bio=bio, favorites=favorites_list, friends=friends_list, is_restricted=False, )