@@ -98,13 +96,7 @@
{% if user %}
+
-
-
-
-
+{% call modal('create-project-modal', 'Create Project') %}
-
-
+{% endcall %}
{% endif %}
{% endblock %}
diff --git a/devplacepy/templating.py b/devplacepy/templating.py
index 8748528..ba434c9 100644
--- a/devplacepy/templating.py
+++ b/devplacepy/templating.py
@@ -5,6 +5,7 @@ from devplacepy.constants import TOPICS
from devplacepy.database import get_table
from devplacepy.avatar import avatar_url
from devplacepy.utils import format_date as _format_date
+from devplacepy.utils import badge_info
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = TTLCache(ttl=60)
@@ -30,6 +31,7 @@ templates.env.globals["get_unread_count"] = jinja_unread_count
templates.env.globals["get_user_projects"] = jinja_user_projects
templates.env.globals["avatar_url"] = avatar_url
templates.env.globals["format_date"] = _format_date
+templates.env.globals["badge_info"] = badge_info
templates.env.globals["TOPICS"] = TOPICS
def jinja_max_upload_size_mb():
from devplacepy.database import get_int_setting
diff --git a/devplacepy/utils.py b/devplacepy/utils.py
index fb34ffd..58be498 100644
--- a/devplacepy/utils.py
+++ b/devplacepy/utils.py
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta, timezone
from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status
from devplacepy.cache import TTLCache
-from devplacepy.database import get_table
+from devplacepy.database import get_table, get_user_stars
from devplacepy.config import SESSION_MAX_AGE
logger = logging.getLogger(__name__)
@@ -87,6 +87,10 @@ def require_admin(request: Request):
return user
+def not_found(detail: str = "Not found") -> HTTPException:
+ return HTTPException(status_code=404, detail=detail)
+
+
def require_user_api(request: Request):
user = get_current_user(request)
if not user:
@@ -207,6 +211,91 @@ def award_badge(user_uid: str, badge_name: str) -> bool:
return True
+LEVEL_XP = 100
+
+XP_POST = 10
+XP_COMMENT = 2
+XP_PROJECT = 15
+XP_GIST = 5
+XP_UPVOTE = 5
+XP_FOLLOW = 5
+
+LEVEL_BADGES = {5: "Level 5", 10: "Level 10"}
+
+BADGE_CATALOG = {
+ "Member": {"icon": "✦", "description": "Joined DevPlace"},
+ "First Post": {"icon": "✎", "description": "Published a first post"},
+ "First Comment": {"icon": "❝", "description": "Wrote a first comment"},
+ "First Project": {"icon": "⬢", "description": "Shared a first project"},
+ "First Gist": {"icon": "❡", "description": "Shared a first gist"},
+ "Prolific": {"icon": "✺", "description": "Published 10 posts"},
+ "Rising Star": {"icon": "☆", "description": "Earned 25 stars"},
+ "Star Author": {"icon": "★", "description": "Earned 100 stars"},
+ "Popular": {"icon": "◎", "description": "Reached 10 followers"},
+ "Level 5": {"icon": "❖", "description": "Reached level 5"},
+ "Level 10": {"icon": "❖", "description": "Reached level 10"},
+}
+
+
+def badge_info(badge_name: str) -> dict:
+ return BADGE_CATALOG.get(badge_name, {"icon": "✦", "description": badge_name})
+
+
+def level_for_xp(xp: int) -> int:
+ return 1 + max(0, xp) // LEVEL_XP
+
+
+def notify_badge(user_uid: str, badge_name: str) -> None:
+ user = get_table("users").find_one(uid=user_uid)
+ if not user:
+ return
+ create_notification(user_uid, "badge", f"You earned the {badge_name} badge", user_uid, f"/profile/{user['username']}")
+
+
+def award_xp(user_uid: str, amount: int) -> dict:
+ users = get_table("users")
+ user = users.find_one(uid=user_uid)
+ if not user or amount <= 0:
+ return {"xp": user.get("xp", 0) if user else 0, "level": user.get("level", 1) if user else 1, "leveled_up": False}
+ current_xp = user.get("xp", 0) or 0
+ current_level = user.get("level", 1) or 1
+ new_xp = max(0, current_xp + amount)
+ new_level = level_for_xp(new_xp)
+ users.update({"uid": user_uid, "xp": new_xp, "level": new_level}, ["uid"])
+ clear_user_cache(user_uid)
+ leveled_up = new_level > current_level
+ if leveled_up:
+ create_notification(user_uid, "level", f"You reached level {new_level}", user_uid, f"/profile/{user['username']}")
+ for level in range(current_level + 1, new_level + 1):
+ badge_name = LEVEL_BADGES.get(level)
+ if badge_name and award_badge(user_uid, badge_name):
+ notify_badge(user_uid, badge_name)
+ return {"xp": new_xp, "level": new_level, "leveled_up": leveled_up}
+
+
+def check_milestone_badges(user_uid: str) -> list:
+ awarded = []
+ if get_table("posts").count(user_uid=user_uid) >= 10 and award_badge(user_uid, "Prolific"):
+ awarded.append("Prolific")
+ stars = get_user_stars(user_uid)
+ if stars >= 100 and award_badge(user_uid, "Star Author"):
+ awarded.append("Star Author")
+ if stars >= 25 and award_badge(user_uid, "Rising Star"):
+ awarded.append("Rising Star")
+ if get_table("follows").count(following_uid=user_uid) >= 10 and award_badge(user_uid, "Popular"):
+ awarded.append("Popular")
+ for badge_name in awarded:
+ notify_badge(user_uid, badge_name)
+ return awarded
+
+
+def award_rewards(user_uid: str, amount: int, first_badge: str | None = None) -> None:
+ if first_badge:
+ award_badge(user_uid, first_badge)
+ award_xp(user_uid, amount)
+ check_milestone_badges(user_uid)
+
+
def create_mention_notifications(content: str, actor_uid: str, target_url: str) -> None:
usernames = extract_mentions(content)
if not usernames:
diff --git a/tests/test_feed.py b/tests/test_feed.py
index 8a401a2..92076a1 100644
--- a/tests/test_feed.py
+++ b/tests/test_feed.py
@@ -1,6 +1,24 @@
+import re
+
+from playwright.sync_api import expect
+
from tests.conftest import BASE_URL, assert_share_copies
+def test_feed_vote_voted_state_persists(alice):
+ page, _ = alice
+ page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
+ page.locator(".feed-fab").first.click()
+ page.fill("#post-content", "Feed vote persistence test content")
+ page.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
+ page.wait_for_url(f"{BASE_URL}/posts/*", wait_until="domcontentloaded")
+ page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
+ page.locator(".post-action-btn.vote-up").first.click()
+ expect(page.locator(".post-vote-count").first).to_have_text("1")
+ page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
+ expect(page.locator(".post-action-btn.vote-up").first).to_have_class(re.compile(r"\bvoted\b"))
+
+
def test_feed_card_share_button(alice):
page, _ = alice
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
diff --git a/tests/test_gists.py b/tests/test_gists.py
index 5573411..ea41cc7 100644
--- a/tests/test_gists.py
+++ b/tests/test_gists.py
@@ -1,4 +1,8 @@
+import re
import time
+
+from playwright.sync_api import expect
+
from tests.conftest import BASE_URL, assert_share_copies
@@ -195,3 +199,13 @@ def test_gist_listing_shows_created_gist(alice, app_server):
_create_gist(page, title=title, source_code="show_in_list = True")
page.goto(f"{BASE_URL}/gists", wait_until="domcontentloaded")
assert page.is_visible(f"text={title}")
+
+
+def test_gist_voted_state_persists(alice):
+ page, _ = alice
+ _create_gist(page, title="Voted State Gist")
+ star = "form[action*='/votes/gist/'] button"
+ page.locator(star).first.click()
+ page.wait_for_timeout(500)
+ page.reload(wait_until="domcontentloaded")
+ expect(page.locator(star).first).to_have_class(re.compile(r"\bvoted\b"))
diff --git a/tests/test_leaderboard.py b/tests/test_leaderboard.py
new file mode 100644
index 0000000..7b8a3a5
--- /dev/null
+++ b/tests/test_leaderboard.py
@@ -0,0 +1,62 @@
+from tests.conftest import BASE_URL
+
+
+def test_leaderboard_page_loads(alice):
+ page, _ = alice
+ page.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
+ assert page.locator(".leaderboard-header h1").is_visible()
+ assert page.is_visible("text=Ranked by total stars")
+
+
+def test_leaderboard_nav_link(alice):
+ page, _ = alice
+ page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
+ assert page.is_visible("a.topnav-link:has-text('Leaderboard')")
+
+
+def test_leaderboard_no_pagination(alice):
+ page, _ = alice
+ page.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
+ assert page.locator(".pagination").count() == 0
+
+
+def test_profile_rank_stat(alice):
+ page, user = alice
+ page.goto(f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded")
+ assert page.is_visible("text=Rank")
+
+
+def test_leaderboard_ranks_after_upvote(app_server, browser, seeded_db):
+ from tests.conftest import login_user
+ ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
+ ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
+ pa = ctx_a.new_page()
+ pb = ctx_b.new_page()
+ pa.set_default_timeout(15000)
+ pb.set_default_timeout(15000)
+
+ login_user(pa, seeded_db["alice"])
+ login_user(pb, seeded_db["bob"])
+
+ pb.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
+ pb.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
+ pb.locator(".feed-fab").first.click()
+ pb.fill("#post-content", "Post for leaderboard ranking test")
+ pb.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
+ pb.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
+ post_url = pb.url
+
+ pa.goto(post_url, wait_until="domcontentloaded")
+ pa.wait_for_timeout(1000)
+ vote_btn = pa.locator("button.post-action-btn").filter(has_text="+").first
+ vote_btn.wait_for(state="visible", timeout=10000)
+ vote_btn.click()
+ pa.wait_for_timeout(1500)
+
+ pa.goto(f"{BASE_URL}/leaderboard", wait_until="domcontentloaded")
+ body = pa.locator("body").text_content()
+ assert "Internal Server Error" not in body, f"Got 500 on leaderboard: {body[:300]}"
+ assert pa.is_visible("a.leaderboard-name:has-text('bob_test')")
+
+ ctx_a.close()
+ ctx_b.close()
diff --git a/tests/test_post.py b/tests/test_post.py
index 0c9b19f..ebfa62a 100644
--- a/tests/test_post.py
+++ b/tests/test_post.py
@@ -1,3 +1,5 @@
+import re
+
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
@@ -49,6 +51,39 @@ def test_post_vote_increment(alice):
expect(page.locator(".post-vote-count").first).to_have_text("1")
+def test_post_upvote_voted_state_persists(alice):
+ page, _ = alice
+ create_post(page, "showcase", "Upvote persistence test")
+ page.locator(".post-action-btn.vote-up").first.click()
+ expect(page.locator(".post-vote-count").first).to_have_text("1")
+ page.reload(wait_until="domcontentloaded")
+ expect(page.locator(".post-action-btn.vote-up").first).to_have_class(re.compile(r"\bvoted\b"))
+ assert "voted" not in (page.locator(".post-action-btn.vote-down").first.get_attribute("class") or "")
+
+
+def test_post_downvote_voted_state_persists(alice):
+ page, _ = alice
+ create_post(page, "showcase", "Downvote persistence test")
+ page.locator(".post-action-btn.vote-down").first.click()
+ expect(page.locator(".post-vote-count").first).to_have_text("-1")
+ page.reload(wait_until="domcontentloaded")
+ expect(page.locator(".post-action-btn.vote-down").first).to_have_class(re.compile(r"\bvoted\b"))
+ assert "voted" not in (page.locator(".post-action-btn.vote-up").first.get_attribute("class") or "")
+
+
+def test_comment_voted_state_persists(alice):
+ page, _ = alice
+ create_post(page, "devlog", "Post for comment vote persistence")
+ textarea = page.locator(".comment-form textarea[name='content']")
+ textarea.fill("Comment whose vote should persist")
+ page.locator(".comment-form button:has-text('Post')").click()
+ page.wait_for_timeout(500)
+ page.locator(".comment-vote-btn").first.click()
+ page.wait_for_timeout(500)
+ page.reload(wait_until="domcontentloaded")
+ expect(page.locator(".comment-vote-btn").first).to_have_class(re.compile(r"\bvoted\b"))
+
+
def _profile_stars(page, username):
page.goto(f"{BASE_URL}/profile/{username}", wait_until="domcontentloaded")
value = page.locator(".profile-stat:has(.profile-stat-label:has-text('Stars')) .profile-stat-value").first
diff --git a/tests/test_projects.py b/tests/test_projects.py
index 08f4490..c88c701 100644
--- a/tests/test_projects.py
+++ b/tests/test_projects.py
@@ -1,3 +1,5 @@
+import re
+
from playwright.sync_api import expect
from tests.conftest import BASE_URL, assert_share_copies
@@ -28,6 +30,16 @@ def test_project_vote(alice):
assert after == before + 1
+def test_project_voted_state_persists(alice):
+ page, _ = alice
+ _create_project(page, "Voted State Project")
+ star = "form[action*='/votes/project/'] button"
+ page.locator(star).first.click()
+ page.wait_for_timeout(500)
+ page.reload(wait_until="domcontentloaded")
+ expect(page.locator(star).first).to_have_class(re.compile(r"\bvoted\b"))
+
+
def test_delete_own_project(alice):
page, _ = alice
_create_project(page, "Deletable Project XYZ")
diff --git a/tests/test_utils.py b/tests/test_utils.py
index a3f8f01..2e0bdbe 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,4 +1,4 @@
-from devplacepy.utils import hash_password, verify_password, generate_uid, slugify, time_ago
+from devplacepy.utils import hash_password, verify_password, generate_uid, slugify, time_ago, level_for_xp, badge_info
from datetime import datetime, timedelta, timezone
@@ -80,3 +80,27 @@ def test_time_ago_years():
dt = (datetime.now(timezone.utc) - timedelta(days=400)).isoformat()
result = time_ago(dt)
assert "/" in result
+
+
+def test_level_for_xp_boundaries():
+ assert level_for_xp(0) == 1
+ assert level_for_xp(99) == 1
+ assert level_for_xp(100) == 2
+ assert level_for_xp(250) == 3
+ assert level_for_xp(450) == 5
+
+
+def test_level_for_xp_negative():
+ assert level_for_xp(-50) == 1
+
+
+def test_badge_info_known():
+ meta = badge_info("Star Author")
+ assert meta["icon"]
+ assert meta["description"] == "Earned 100 stars"
+
+
+def test_badge_info_unknown_fallback():
+ meta = badge_info("Nonexistent Badge")
+ assert meta["icon"]
+ assert meta["description"] == "Nonexistent Badge"