Merge pull request 'feat: Expose level progress percentage in profile API response' (#144) from typosaurus/112-expose-level-progress-percentage-in-profile-api-response into master
Some checks are pending
DevPlace CI / test (push) Waiting to run
Some checks are pending
DevPlace CI / test (push) Waiting to run
Reviewed-on: #144
This commit is contained in:
commit
3709f4fab9
@ -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.
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@ -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})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -42,10 +42,10 @@
|
||||
<div class="profile-level-bar">
|
||||
<div class="level-label">
|
||||
<span>Progress to next level</span>
|
||||
<span>{{ (profile_user.get('xp') or 0) % 100 }}%</span>
|
||||
<span>{{ xp_progress_pct }}%</span>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<div class="bar-fill" style="--bar-pct: {{ (profile_user.get('xp') or 0) % 100 }}%;"></div>
|
||||
<div class="bar-fill" style="--bar-pct: {{ xp_progress_pct }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -765,3 +765,6 @@ import { AwardGiver } from "{{ static_url('/static/js/AwardGiver.js') }}";
|
||||
new AwardGiver();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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']}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user