""" 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!" }