Add public profile view endpoint: favorites, friends, privacy enforcement (US-3)
- 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.
This commit is contained in:
@@ -17,7 +17,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, or_, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
@@ -30,6 +30,8 @@ from app.models.profile import (
|
||||
ProfileFavorite,
|
||||
ProfileVisibility,
|
||||
)
|
||||
from app.models.social import Friendship, Block, FriendshipStatus
|
||||
from app.models.user import User
|
||||
from app.models.world import World
|
||||
|
||||
router = APIRouter()
|
||||
@@ -43,6 +45,30 @@ _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
|
||||
@@ -203,3 +229,137 @@ async def replace_my_favorites(payload: FavoritesUpdate, current_user: CurrentUs
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -251,3 +251,156 @@ class TestFavorites:
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/profile/me/favorites").status_code == 401
|
||||
assert client.put("/api/profile/me/favorites", json={"world_ids": []}).status_code == 401
|
||||
|
||||
|
||||
class TestPublicProfile:
|
||||
def test_public_profile_full_view(self):
|
||||
client = TestClient(app)
|
||||
h_alice = _auth_headers(client, "pub_alice")
|
||||
alice_id = _me_id(client, h_alice)
|
||||
h_bob = _auth_headers(client, "pub_bob")
|
||||
|
||||
w1 = _make_world(client, h_alice, "AliceWorld")
|
||||
asset_id = _make_asset(client, h_alice, "AliceAsset")
|
||||
|
||||
client.put(
|
||||
"/api/profile/me",
|
||||
headers=h_alice,
|
||||
json={"bio": "Hi, I am Alice", "avatar_asset_id": asset_id},
|
||||
)
|
||||
client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]})
|
||||
|
||||
resp = client.get(f"/api/profile/{alice_id}", headers=h_bob)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["user_id"] == alice_id
|
||||
assert data["username"] == "pub_alice"
|
||||
assert data["bio"] == "Hi, I am Alice"
|
||||
assert data["avatar_asset_id"] == asset_id
|
||||
assert data["is_restricted"] is False
|
||||
assert len(data["favorites"]) == 1
|
||||
assert data["favorites"][0]["id"] == w1
|
||||
assert data["favorites"][0]["name"] == "AliceWorld"
|
||||
assert "email" not in data
|
||||
assert "password" not in data
|
||||
|
||||
def test_public_profile_not_found(self):
|
||||
client = TestClient(app)
|
||||
h_bob = _auth_headers(client, "pub_nf_bob")
|
||||
resp = client.get("/api/profile/999999", headers=h_bob)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_public_profile_requires_auth(self):
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/profile/1").status_code == 401
|
||||
|
||||
def test_public_profile_private_visibility(self):
|
||||
client = TestClient(app)
|
||||
h_alice = _auth_headers(client, "pub_priv_a")
|
||||
alice_id = _me_id(client, h_alice)
|
||||
h_bob = _auth_headers(client, "pub_priv_b")
|
||||
|
||||
client.put(
|
||||
"/api/profile/me",
|
||||
headers=h_alice,
|
||||
json={"bio": "Top secret bio", "privacy": {"profile_visibility": "private"}},
|
||||
)
|
||||
|
||||
resp_bob = client.get(f"/api/profile/{alice_id}", headers=h_bob)
|
||||
assert resp_bob.status_code == 200
|
||||
data_bob = resp_bob.json()
|
||||
assert data_bob["is_restricted"] is True
|
||||
assert data_bob["bio"] is None
|
||||
assert data_bob["favorites"] == []
|
||||
assert data_bob["friends"] == []
|
||||
|
||||
# Alice viewing herself is not restricted
|
||||
resp_alice = client.get(f"/api/profile/{alice_id}", headers=h_alice)
|
||||
assert resp_alice.status_code == 200
|
||||
assert resp_alice.json()["is_restricted"] is False
|
||||
assert resp_alice.json()["bio"] == "Top secret bio"
|
||||
|
||||
def test_public_profile_friends_only_visibility(self):
|
||||
client = TestClient(app)
|
||||
h_alice = _auth_headers(client, "pub_fo_a")
|
||||
alice_id = _me_id(client, h_alice)
|
||||
h_bob = _auth_headers(client, "pub_fo_b")
|
||||
bob_id = _me_id(client, h_bob)
|
||||
h_charlie = _auth_headers(client, "pub_fo_c")
|
||||
|
||||
client.put(
|
||||
"/api/profile/me",
|
||||
headers=h_alice,
|
||||
json={"bio": "Friends only bio", "privacy": {"profile_visibility": "friends"}},
|
||||
)
|
||||
|
||||
# Charlie is not a friend -> restricted
|
||||
resp_c = client.get(f"/api/profile/{alice_id}", headers=h_charlie)
|
||||
assert resp_c.json()["is_restricted"] is True
|
||||
assert resp_c.json()["bio"] is None
|
||||
|
||||
# Bob becomes friends with Alice
|
||||
req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id})
|
||||
req_id = req.json()["id"]
|
||||
client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice)
|
||||
|
||||
# Now Bob sees full profile and friends list contains Bob
|
||||
resp_b = client.get(f"/api/profile/{alice_id}", headers=h_bob)
|
||||
assert resp_b.json()["is_restricted"] is False
|
||||
assert resp_b.json()["bio"] == "Friends only bio"
|
||||
assert any(f["id"] == bob_id for f in resp_b.json()["friends"])
|
||||
|
||||
def test_public_profile_blocked_user_gets_restricted(self):
|
||||
client = TestClient(app)
|
||||
h_alice = _auth_headers(client, "pub_blk_a")
|
||||
alice_id = _me_id(client, h_alice)
|
||||
h_dan = _auth_headers(client, "pub_blk_d")
|
||||
dan_id = _me_id(client, h_dan)
|
||||
|
||||
client.put("/api/profile/me", headers=h_alice, json={"bio": "Alice public bio"})
|
||||
client.post(f"/api/friends/{dan_id}/block", headers=h_alice)
|
||||
|
||||
# Dan viewing Alice gets restricted view
|
||||
resp_dan = client.get(f"/api/profile/{alice_id}", headers=h_dan)
|
||||
assert resp_dan.status_code == 200
|
||||
assert resp_dan.json()["is_restricted"] is True
|
||||
assert resp_dan.json()["bio"] is None
|
||||
|
||||
# Alice viewing Dan gets restricted view
|
||||
resp_alice = client.get(f"/api/profile/{dan_id}", headers=h_alice)
|
||||
assert resp_alice.status_code == 200
|
||||
assert resp_alice.json()["is_restricted"] is True
|
||||
|
||||
def test_public_profile_selective_favorites_and_friends_privacy(self):
|
||||
client = TestClient(app)
|
||||
h_alice = _auth_headers(client, "pub_sec_a")
|
||||
alice_id = _me_id(client, h_alice)
|
||||
h_bob = _auth_headers(client, "pub_sec_b")
|
||||
bob_id = _me_id(client, h_bob)
|
||||
w1 = _make_world(client, h_alice, "WorldSec")
|
||||
|
||||
client.put("/api/profile/me/favorites", headers=h_alice, json={"world_ids": [w1]})
|
||||
req = client.post("/api/friends/requests", headers=h_bob, json={"user_id": alice_id})
|
||||
req_id = req.json()["id"]
|
||||
client.post(f"/api/friends/requests/{req_id}/accept", headers=h_alice)
|
||||
|
||||
client.put(
|
||||
"/api/profile/me",
|
||||
headers=h_alice,
|
||||
json={
|
||||
"bio": "Bio is public",
|
||||
"privacy": {
|
||||
"profile_visibility": "public",
|
||||
"favorites_visibility": "private",
|
||||
"friends_list_visibility": "private",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/profile/{alice_id}", headers=h_bob)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["bio"] == "Bio is public"
|
||||
assert data["favorites"] == []
|
||||
assert data["friends"] == []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user