elmeg-demo/backend/routers/gamification.py
fullsizemalt bc804a666b feat: Gamification sprint complete
XP System:
- XP now awarded for attendance (+25), ratings (+10), reviews (+50)
- First-time bonuses for first rating (+25) and first review (+50)
- Streak bonuses (+10 per day, capped at 7x)
- Badge awards automatically grant XP

User Titles & Flair System (Tracker-style):
- Level-based free titles: Rookie → Immortal
- Purchasable titles with XP: Jam Connoisseur, Setlist Savant, etc.
- Username colors purchasable with XP (6 colors + Rainbow)
- Emoji flairs purchasable with XP
- Early adopter perks: exclusive titles, colors, 10% XP bonus

New Fields on User:
- custom_title, title_color, flair
- is_early_adopter, is_supporter
- joined_at

Shop API Endpoints:
- GET /gamification/shop/titles
- POST /gamification/shop/titles/purchase
- GET/POST for colors and flairs
- GET /gamification/user/{id}/display
- GET /gamification/early-adopter-perks

Frontend:
- XP Leaderboard added to home page
- LevelProgressCard shows on profile
2025-12-21 19:21:20 -08:00

371 lines
9.6 KiB
Python

"""
Gamification Router - XP, Levels, Leaderboards
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from typing import List
from pydantic import BaseModel
from datetime import datetime
from database import get_session
from models import User, Badge, UserBadge
from routers.auth import get_current_user
from services.gamification import (
calculate_level,
xp_for_next_level,
update_streak,
check_and_award_badges,
get_leaderboard,
seed_badges,
LEVEL_NAMES,
XP_REWARDS,
)
router = APIRouter(prefix="/gamification", tags=["gamification"])
class LevelProgress(BaseModel):
current_xp: int
level: int
level_name: str
xp_for_next: int
xp_progress: int
progress_percent: float
streak_days: int
class LeaderboardEntry(BaseModel):
rank: int
username: str
xp: int
level: int
level_name: str
streak: int
class BadgeResponse(BaseModel):
id: int
name: str
description: str
icon: str
slug: str
tier: str
category: str
awarded_at: datetime | None = None
class XPRewardsInfo(BaseModel):
rewards: dict
level_thresholds: List[int]
level_names: dict
@router.get("/me", response_model=LevelProgress)
async def get_my_progress(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get current user's XP and level progress"""
xp_needed, xp_progress = xp_for_next_level(current_user.xp)
progress_percent = (xp_progress / xp_needed * 100) if xp_needed > 0 else 100
return LevelProgress(
current_xp=current_user.xp,
level=current_user.level,
level_name=LEVEL_NAMES.get(current_user.level, "Unknown"),
xp_for_next=xp_needed,
xp_progress=xp_progress,
progress_percent=round(progress_percent, 1),
streak_days=current_user.streak_days,
)
@router.post("/activity")
async def record_activity(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Record user activity and update streak"""
streak = update_streak(session, current_user)
new_badges = check_and_award_badges(session, current_user)
session.commit()
return {
"streak_days": streak,
"new_badges": [b.name for b in new_badges],
"xp": current_user.xp,
"level": current_user.level,
}
@router.get("/leaderboard", response_model=List[LeaderboardEntry])
async def get_xp_leaderboard(
limit: int = 10,
session: Session = Depends(get_session)
):
"""Get top users by XP"""
leaders = get_leaderboard(session, limit)
return [
LeaderboardEntry(
rank=i + 1,
username=l["email"],
xp=l["xp"],
level=l["level"],
level_name=l["level_name"],
streak=l["streak"],
)
for i, l in enumerate(leaders)
]
@router.get("/badges", response_model=List[BadgeResponse])
async def get_all_badges(session: Session = Depends(get_session)):
"""Get all available badges"""
badges = session.exec(select(Badge).order_by(Badge.tier, Badge.category)).all()
return [
BadgeResponse(
id=b.id,
name=b.name,
description=b.description,
icon=b.icon,
slug=b.slug,
tier=b.tier,
category=b.category,
)
for b in badges
]
@router.get("/badges/me", response_model=List[BadgeResponse])
async def get_my_badges(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get current user's earned badges"""
user_badges = session.exec(
select(UserBadge, Badge)
.join(Badge)
.where(UserBadge.user_id == current_user.id)
.order_by(UserBadge.awarded_at.desc())
).all()
return [
BadgeResponse(
id=b.id,
name=b.name,
description=b.description,
icon=b.icon,
slug=b.slug,
tier=b.tier,
category=b.category,
awarded_at=ub.awarded_at,
)
for ub, b in user_badges
]
@router.get("/info", response_model=XPRewardsInfo)
async def get_xp_info():
"""Get XP reward values and level thresholds"""
from services.gamification import LEVEL_THRESHOLDS
return XPRewardsInfo(
rewards=XP_REWARDS,
level_thresholds=LEVEL_THRESHOLDS,
level_names=LEVEL_NAMES,
)
@router.post("/seed-badges")
async def seed_badge_data(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Seed badge definitions (admin only)"""
if current_user.role not in ["admin", "moderator"]:
raise HTTPException(status_code=403, detail="Admin access required")
seed_badges(session)
return {"message": "Badges seeded successfully"}
# ==========================================
# TITLE/FLAIR SHOP ENDPOINTS
# ==========================================
from services.gamification import (
get_available_titles,
get_available_colors,
get_available_flairs,
purchase_title,
purchase_color,
purchase_flair,
get_user_display,
EARLY_ADOPTER_PERKS,
)
class ShopItem(BaseModel):
name: str
type: str
cost: int
level_required: int
is_owned: bool = False
class PurchaseRequest(BaseModel):
item_name: str
@router.get("/shop/titles")
async def get_title_shop(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get available titles for purchase"""
available = get_available_titles(current_user)
return {
"current_title": current_user.custom_title,
"current_xp": current_user.xp,
"items": [
{
"name": title,
"type": info["type"],
"cost": info["cost"],
"level_required": info["level_required"],
"is_owned": current_user.custom_title == title,
}
for title, info in available.items()
]
}
@router.post("/shop/titles/purchase")
async def buy_title(
request: PurchaseRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Purchase a title with XP"""
success, message = purchase_title(session, current_user, request.item_name)
if not success:
raise HTTPException(status_code=400, detail=message)
return {
"success": True,
"message": message,
"new_title": current_user.custom_title,
"remaining_xp": current_user.xp,
}
@router.get("/shop/colors")
async def get_color_shop(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get available username colors for purchase"""
available = get_available_colors(current_user)
return {
"current_color": current_user.title_color,
"current_xp": current_user.xp,
"items": [
{
"name": name,
"hex": info["hex"],
"cost": info["cost"],
"is_owned": current_user.title_color == info["hex"],
}
for name, info in available.items()
]
}
@router.post("/shop/colors/purchase")
async def buy_color(
request: PurchaseRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Purchase a username color with XP"""
success, message = purchase_color(session, current_user, request.item_name)
if not success:
raise HTTPException(status_code=400, detail=message)
return {
"success": True,
"message": message,
"new_color": current_user.title_color,
"remaining_xp": current_user.xp,
}
@router.get("/shop/flairs")
async def get_flair_shop(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Get available flairs for purchase"""
available = get_available_flairs(current_user)
return {
"current_flair": current_user.flair,
"current_xp": current_user.xp,
"items": [
{
"name": flair,
"cost": info["cost"],
"is_owned": current_user.flair == flair,
}
for flair, info in available.items()
]
}
@router.post("/shop/flairs/purchase")
async def buy_flair(
request: PurchaseRequest,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Purchase a flair with XP"""
success, message = purchase_flair(session, current_user, request.item_name)
if not success:
raise HTTPException(status_code=400, detail=message)
return {
"success": True,
"message": message,
"new_flair": current_user.flair,
"remaining_xp": current_user.xp,
}
@router.get("/user/{user_id}/display")
async def get_user_display_info(
user_id: int,
session: Session = Depends(get_session)
):
"""Get a user's display info (title, color, flair)"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return get_user_display(user)
@router.get("/early-adopter-perks")
async def get_early_adopter_info():
"""Get information about early adopter perks"""
return {
"perks": EARLY_ADOPTER_PERKS,
"description": "The first 100 users get exclusive perks including unique titles, colors, "
"and a 10% XP bonus on all actions!"
}