diff --git a/devplacepy/docs_api/groups/profiles.py b/devplacepy/docs_api/groups/profiles.py index 7ebdb25c..03bf8dea 100644 --- a/devplacepy/docs_api/groups/profiles.py +++ b/devplacepy/docs_api/groups/profiles.py @@ -41,9 +41,12 @@ four ways to sign requests. method="GET", path="/profile/{username}", title="View a profile", - summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online and profile_user.last_seen). Returns an HTML page.", + summary="Render a user profile, including an online-presence indicator (JSON exposes profile_online, profile_user.last_seen, xp_next_level, and xp_progress_pct). Returns an HTML page.", auth="public", interactive=True, + notes=[ + "Level progress: `xp_next_level = level * 100` (total XP needed), `xp_progress_pct = xp % 100` (percentage towards next level). Both are also embedded in `profile_user`.", + ], params=[ field( "username", @@ -793,3 +796,4 @@ four ways to sign requests. ), ], } + diff --git a/devplacepy/routers/profile/index.py b/devplacepy/routers/profile/index.py index d4e8fc4c..5c958e37 100644 --- a/devplacepy/routers/profile/index.py +++ b/devplacepy/routers/profile/index.py @@ -46,6 +46,7 @@ from devplacepy.utils import ( track_action, build_achievements, ) +from devplacepy.utils.rewards import LEVEL_XP from devplacepy.responses import respond, action_result, wants_json from devplacepy.schemas import ProfileOut from devplacepy.avatar import avatar_url, avatar_seed @@ -139,6 +140,10 @@ async def profile_page( current_user["uid"], f"/profile/{profile_user['username']}" ) profile_user["stars"] = get_user_stars(profile_user["uid"]) + xp_raw = profile_user.get("xp") or 0 + level_raw = profile_user.get("level") or 1 + xp_progress_pct = xp_raw % LEVEL_XP + xp_next_level = level_raw * LEVEL_XP rank = get_user_rank(profile_user["uid"]) follow_counts = get_follow_counts(profile_user["uid"]) @@ -440,6 +445,8 @@ async def profile_page( "awards_count": awards_count, "prominent_award": prominent_award, "can_give_award": can_give, + "xp_next_level": xp_next_level, + "xp_progress_pct": xp_progress_pct, }, model=ProfileOut, ) @@ -499,3 +506,7 @@ async def regenerate_api_key(request: Request): links=[audit.target("user", user["uid"], user["username"])], ) return JSONResponse({"api_key": new_key}) + + + + diff --git a/devplacepy/schemas/content.py b/devplacepy/schemas/content.py index 860cd0ee..14ef907a 100644 --- a/devplacepy/schemas/content.py +++ b/devplacepy/schemas/content.py @@ -17,6 +17,8 @@ class UserOut(_Out): website: Optional[str] = None level: Optional[int] = None xp: Optional[int] = None + xp_progress_pct: Optional[int] = None + xp_next_level: Optional[int] = None stars: Optional[int] = None created_at: Optional[str] = None last_seen: Optional[str] = None @@ -202,3 +204,4 @@ class MessageOut(_Out): CommentItemOut.model_rebuild() + diff --git a/devplacepy/schemas/profile.py b/devplacepy/schemas/profile.py index d19d4465..3fc3a4a2 100644 --- a/devplacepy/schemas/profile.py +++ b/devplacepy/schemas/profile.py @@ -72,6 +72,8 @@ class ProfileOut(_Out): followers_count: Optional[int] = None following_count: Optional[int] = None viewer_is_admin: bool = False + xp_next_level: int = 0 + xp_progress_pct: int = 0 media: list[MediaItemOut] = [] media_pagination: Optional[Any] = None notification_prefs: list[Any] = [] @@ -88,3 +90,4 @@ class TelegramPairOut(_Out): code: Optional[str] = None expires_at: Optional[str] = None ttl_minutes: Optional[int] = None + diff --git a/devplacepy/templates/profile.html b/devplacepy/templates/profile.html index c24562b1..11bb2204 100644 --- a/devplacepy/templates/profile.html +++ b/devplacepy/templates/profile.html @@ -42,10 +42,10 @@
@@ -765,3 +765,6 @@ import { AwardGiver } from "{{ static_url('/static/js/AwardGiver.js') }}"; new AwardGiver(); {% endblock %} + + + diff --git a/tests/api/profile/index.py b/tests/api/profile/index.py index d50535e0..8640f3fb 100644 --- a/tests/api/profile/index.py +++ b/tests/api/profile/index.py @@ -407,3 +407,96 @@ def test_viewing_profile_marks_notification_read(app_server): refresh_snapshot() assert bool(get_table("notifications").find_one(uid=notif_uid)["read"]) is True + + +def test_own_profile_json_exposes_xp_fields(app_server, seeded_db): + """GET /profile (own) with Accept: application/json includes xp_next_level and xp_progress_pct.""" + import requests + + session = requests.Session() + session.post( + f"{BASE_URL}/auth/login", + data={ + "username": seeded_db["alice"]["username"], + "password": seeded_db["alice"]["password"], + }, + allow_redirects=True, + ) + r = session.get(f"{BASE_URL}/profile", headers={"Accept": "application/json"}) + assert r.status_code == 200 + data = r.json() + pu = data["profile_user"] + assert "xp_next_level" in pu, "xp_next_level missing from own profile JSON" + assert "xp_progress_pct" in pu, "xp_progress_pct missing from own profile JSON" + assert isinstance(pu["xp_next_level"], int) + assert isinstance(pu["xp_progress_pct"], int) + + +def test_other_profile_json_exposes_xp_fields(app_server): + """GET /profile/{username} with Accept: application/json includes xp_next_level and xp_progress_pct.""" + from devplacepy.database import get_table, refresh_snapshot + + owner = _seed_owner() + username = get_table("users").find_one(uid=owner)["username"] + refresh_snapshot() + + r = requests.get( + f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"} + ) + assert r.status_code == 200 + data = r.json() + pu = data["profile_user"] + assert "xp_next_level" in pu, "xp_next_level missing from other profile JSON" + assert "xp_progress_pct" in pu, "xp_progress_pct missing from other profile JSON" + assert isinstance(pu["xp_next_level"], int) + assert isinstance(pu["xp_progress_pct"], int) + + +def test_profile_json_xp_fields_zero_xp(app_server): + """User with 0 XP returns xp_next_level=100 (level 1) and xp_progress_pct=0.""" + from devplacepy.database import get_table, refresh_snapshot + from devplacepy.utils.rewards import LEVEL_XP + + owner = _seed_owner() + username = get_table("users").find_one(uid=owner)["username"] + refresh_snapshot() + + r = requests.get( + f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"} + ) + assert r.status_code == 200 + pu = r.json()["profile_user"] + assert pu["xp"] == 0 or pu["xp"] is None, f"expected 0 xp, got {pu['xp']}" + assert pu["xp_next_level"] == LEVEL_XP, ( + f"expected xp_next_level={LEVEL_XP} for level 1, got {pu['xp_next_level']}" + ) + assert pu["xp_progress_pct"] == 0, ( + f"expected xp_progress_pct=0 for 0 XP, got {pu['xp_progress_pct']}" + ) + + +def test_profile_json_xp_fields_boundary_xp(app_server): + """User with exactly 100 XP (level 2) returns xp_next_level=200 and xp_progress_pct=0.""" + from devplacepy.database import get_table, refresh_snapshot + from devplacepy.utils.rewards import award_xp + + owner = _seed_owner() + username = get_table("users").find_one(uid=owner)["username"] + award_xp(owner, 100) + refresh_snapshot() + + r = requests.get( + f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"} + ) + assert r.status_code == 200 + pu = r.json()["profile_user"] + assert pu["xp"] == 100, f"expected 100 xp, got {pu['xp']}" + assert pu["level"] == 2, f"expected level 2, got {pu['level']}" + assert pu["xp_next_level"] == 200, ( + f"expected xp_next_level=200 for level 2, got {pu['xp_next_level']}" + ) + assert pu["xp_progress_pct"] == 0, ( + f"expected xp_progress_pct=0 at boundary, got {pu['xp_progress_pct']}" + ) + +