elmeg-demo/backend/routers/gamification.py
fullsizemalt 5ffb428bb8 feat: Add gamification system
Backend:
- Add XP, level, streak fields to User model
- Add tier, category, xp_reward fields to Badge model
- Create gamification service with XP, levels, streaks, badge checking
- Add gamification router with level progress, leaderboard endpoints
- Define 16 badge types across attendance, ratings, social, milestones

Frontend:
- LevelProgressCard component with XP bar and streak display
- XPLeaderboard component showing top users
- Integrate level progress into profile page

Slug System:
- All entities now support slug-based URLs
- Performances use songslug-YYYY-MM-DD format
2025-12-21 18:58:42 -08:00

190 lines
4.9 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"}