- Fork elmeg-demo codebase for multi-band support - Add data importer infrastructure with base class - Create band-specific importers: - phish.py: Phish.net API v5 - grateful_dead.py: Grateful Stats API - setlistfm.py: Dead & Company, Billy Strings (Setlist.fm) - Add spec-kit configuration for Gemini - Update README with supported bands and architecture
371 lines
9.6 KiB
Python
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!"
|
|
}
|
|
|