0b525d6996
- Implemented GET /api/profile/{user_id} in backend/app/routers/profiles.py
- Server-side privacy enforcement for profile_visibility, favorites_visibility, and friends_list_visibility
- Block relationships enforce restricted view (no bio, empty favorites, empty friends)
- Non-friends restricted when profile_visibility or sections set to friends-only
- Asset public check for avatar in restricted view
- No private user fields leaked (email, password excluded)
- Added 7 integration tests in backend/tests/test_profile.py covering all privacy and block permutations
Independently verified: 26/26 profile tests passing.
366 lines
13 KiB
Python
366 lines
13 KiB
Python
"""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,
|
|
)
|
|
|