Huge v2 uplift, now deployable with real user management and tooling!

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-27 11:32:15 -05:00
parent c912a56c2d
commit bea85e6b28
61 changed files with 25153 additions and 362 deletions

View File

@@ -0,0 +1,9 @@
"""Routers package for Golf game API."""
from .auth import router as auth_router
from .admin import router as admin_router
__all__ = [
"auth_router",
"admin_router",
]

419
server/routers/admin.py Normal file
View File

@@ -0,0 +1,419 @@
"""
Admin API router for Golf game V2.
Provides endpoints for admin operations: user management, game moderation,
system statistics, invite codes, and audit logging.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from models.user import User
from services.admin_service import AdminService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"])
# =============================================================================
# Request/Response Models
# =============================================================================
class BanUserRequest(BaseModel):
"""Ban user request."""
reason: str
duration_days: Optional[int] = None
class ChangeRoleRequest(BaseModel):
"""Change user role request."""
role: str
class CreateInviteRequest(BaseModel):
"""Create invite code request."""
max_uses: int = 1
expires_days: int = 7
class EndGameRequest(BaseModel):
"""End game request."""
reason: str
# =============================================================================
# Dependencies
# =============================================================================
# These will be set by main.py during startup
_admin_service: Optional[AdminService] = None
def set_admin_service(service: AdminService) -> None:
"""Set the admin service instance (called from main.py)."""
global _admin_service
_admin_service = service
def get_admin_service_dep() -> AdminService:
"""Dependency to get admin service."""
if _admin_service is None:
raise HTTPException(status_code=503, detail="Admin service not initialized")
return _admin_service
# Import the auth dependency from the auth router
from routers.auth import require_admin_v2, get_client_ip
# =============================================================================
# User Management Endpoints
# =============================================================================
@router.get("/users")
async def list_users(
query: str = "",
limit: int = 50,
offset: int = 0,
include_banned: bool = True,
include_deleted: bool = False,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Search and list users.
Args:
query: Search by username or email.
limit: Maximum results to return.
offset: Results to skip.
include_banned: Include banned users.
include_deleted: Include soft-deleted users.
"""
users = await service.search_users(
query=query,
limit=limit,
offset=offset,
include_banned=include_banned,
include_deleted=include_deleted,
)
return {"users": [u.to_dict() for u in users]}
@router.get("/users/{user_id}")
async def get_user(
user_id: str,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Get detailed user information."""
user = await service.get_user(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user.to_dict()
@router.get("/users/{user_id}/ban-history")
async def get_user_ban_history(
user_id: str,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Get ban history for a user."""
history = await service.get_user_ban_history(user_id)
return {"history": history}
@router.post("/users/{user_id}/ban")
async def ban_user(
user_id: str,
request_body: BanUserRequest,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Ban a user.
Banning revokes all sessions and optionally removes from active games.
Admins cannot be banned.
"""
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot ban yourself")
success = await service.ban_user(
admin_id=admin.id,
user_id=user_id,
reason=request_body.reason,
duration_days=request_body.duration_days,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=400, detail="Cannot ban user (user not found or is admin)")
return {"message": "User banned successfully"}
@router.post("/users/{user_id}/unban")
async def unban_user(
user_id: str,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Unban a user."""
success = await service.unban_user(
admin_id=admin.id,
user_id=user_id,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=400, detail="Cannot unban user")
return {"message": "User unbanned successfully"}
@router.post("/users/{user_id}/force-password-reset")
async def force_password_reset(
user_id: str,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Force user to reset password on next login.
All existing sessions are revoked.
"""
success = await service.force_password_reset(
admin_id=admin.id,
user_id=user_id,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=400, detail="Cannot force password reset")
return {"message": "Password reset required for user"}
@router.put("/users/{user_id}/role")
async def change_user_role(
user_id: str,
request_body: ChangeRoleRequest,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Change user role.
Valid roles: "user", "admin"
"""
if user_id == admin.id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
if request_body.role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="Invalid role. Must be 'user' or 'admin'")
success = await service.change_user_role(
admin_id=admin.id,
user_id=user_id,
new_role=request_body.role,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=400, detail="Cannot change user role")
return {"message": f"Role changed to {request_body.role}"}
@router.post("/users/{user_id}/impersonate")
async def impersonate_user(
user_id: str,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Start read-only impersonation of a user.
Returns the user's data as they would see it. This is for
debugging and support purposes only.
"""
user = await service.impersonate_user(
admin_id=admin.id,
user_id=user_id,
ip_address=get_client_ip(request),
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"message": "Impersonation started (read-only)",
"user": user.to_dict(),
}
# =============================================================================
# Game Moderation Endpoints
# =============================================================================
@router.get("/games")
async def list_active_games(
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""List all active games."""
games = await service.get_active_games()
return {"games": games}
@router.get("/games/{game_id}")
async def get_game_details(
game_id: str,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Get full game state (admin view).
This view shows all cards, including face-down cards.
"""
game = await service.get_game_details(
admin_id=admin.id,
game_id=game_id,
ip_address=get_client_ip(request),
)
if not game:
raise HTTPException(status_code=404, detail="Game not found")
return game
@router.post("/games/{game_id}/end")
async def end_game(
game_id: str,
request_body: EndGameRequest,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Force-end a stuck or problematic game.
The game will be marked as abandoned.
"""
success = await service.end_game(
admin_id=admin.id,
game_id=game_id,
reason=request_body.reason,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=400, detail="Cannot end game")
return {"message": "Game ended successfully"}
# =============================================================================
# System Stats Endpoints
# =============================================================================
@router.get("/stats")
async def get_system_stats(
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Get current system statistics."""
stats = await service.get_system_stats()
return stats.to_dict()
# =============================================================================
# Audit Log Endpoints
# =============================================================================
@router.get("/audit")
async def get_audit_log(
limit: int = 100,
offset: int = 0,
admin_id: Optional[str] = None,
action: Optional[str] = None,
target_type: Optional[str] = None,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Get admin audit log.
Can filter by admin_id, action type, or target type.
"""
entries = await service.get_audit_log(
limit=limit,
offset=offset,
admin_id=admin_id,
action=action,
target_type=target_type,
)
return {"entries": [e.to_dict() for e in entries]}
# =============================================================================
# Invite Code Endpoints
# =============================================================================
@router.get("/invites")
async def list_invite_codes(
include_expired: bool = False,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""List all invite codes."""
codes = await service.get_invite_codes(include_expired=include_expired)
return {"codes": [c.to_dict() for c in codes]}
@router.post("/invites")
async def create_invite_code(
request_body: CreateInviteRequest,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""
Create a new invite code.
Args:
max_uses: Maximum number of times the code can be used.
expires_days: Number of days until the code expires.
"""
code = await service.create_invite_code(
admin_id=admin.id,
max_uses=request_body.max_uses,
expires_days=request_body.expires_days,
ip_address=get_client_ip(request),
)
return {"code": code, "message": "Invite code created successfully"}
@router.delete("/invites/{code}")
async def revoke_invite_code(
code: str,
request: Request,
admin: User = Depends(require_admin_v2),
service: AdminService = Depends(get_admin_service_dep),
):
"""Revoke an invite code."""
success = await service.revoke_invite_code(
admin_id=admin.id,
code=code,
ip_address=get_client_ip(request),
)
if not success:
raise HTTPException(status_code=404, detail="Invite code not found")
return {"message": "Invite code revoked successfully"}

506
server/routers/auth.py Normal file
View File

@@ -0,0 +1,506 @@
"""
Authentication API router for Golf game V2.
Provides endpoints for user registration, login, password management,
and session handling.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Request
from pydantic import BaseModel, EmailStr
from models.user import User
from services.auth_service import AuthService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
# =============================================================================
# Request/Response Models
# =============================================================================
class RegisterRequest(BaseModel):
"""Registration request."""
username: str
password: str
email: Optional[str] = None
class LoginRequest(BaseModel):
"""Login request."""
username: str
password: str
class VerifyEmailRequest(BaseModel):
"""Email verification request."""
token: str
class ResendVerificationRequest(BaseModel):
"""Resend verification email request."""
email: str
class ForgotPasswordRequest(BaseModel):
"""Forgot password request."""
email: str
class ResetPasswordRequest(BaseModel):
"""Password reset request."""
token: str
new_password: str
class ChangePasswordRequest(BaseModel):
"""Change password request."""
current_password: str
new_password: str
class UpdatePreferencesRequest(BaseModel):
"""Update preferences request."""
preferences: dict
class ConvertGuestRequest(BaseModel):
"""Convert guest to user request."""
guest_id: str
username: str
password: str
email: Optional[str] = None
class UserResponse(BaseModel):
"""User response (public fields only)."""
id: str
username: str
email: Optional[str]
role: str
email_verified: bool
preferences: dict
created_at: str
last_login: Optional[str]
class AuthResponse(BaseModel):
"""Authentication response with token."""
user: UserResponse
token: str
expires_at: str
class SessionResponse(BaseModel):
"""Session response."""
id: str
device_info: dict
ip_address: Optional[str]
created_at: str
last_used_at: str
# =============================================================================
# Dependencies
# =============================================================================
# These will be set by main.py during startup
_auth_service: Optional[AuthService] = None
def set_auth_service(service: AuthService) -> None:
"""Set the auth service instance (called from main.py)."""
global _auth_service
_auth_service = service
def get_auth_service_dep() -> AuthService:
"""Dependency to get auth service."""
if _auth_service is None:
raise HTTPException(status_code=503, detail="Auth service not initialized")
return _auth_service
async def get_current_user_v2(
authorization: Optional[str] = Header(None),
auth_service: AuthService = Depends(get_auth_service_dep),
) -> Optional[User]:
"""Get current user from Authorization header (optional)."""
if not authorization:
return None
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
token = parts[1]
return await auth_service.get_user_from_token(token)
async def require_user_v2(
user: Optional[User] = Depends(get_current_user_v2),
) -> User:
"""Require authenticated user."""
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account disabled")
return user
async def require_admin_v2(
user: User = Depends(require_user_v2),
) -> User:
"""Require admin user."""
if not user.is_admin():
raise HTTPException(status_code=403, detail="Admin access required")
return user
def get_client_ip(request: Request) -> Optional[str]:
"""Extract client IP from request."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return None
def get_device_info(request: Request) -> dict:
"""Extract device info from request headers."""
return {
"user_agent": request.headers.get("user-agent", ""),
}
def get_token_from_header(authorization: Optional[str] = Header(None)) -> Optional[str]:
"""Extract token from Authorization header."""
if not authorization:
return None
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
return parts[1]
# =============================================================================
# Registration Endpoints
# =============================================================================
@router.post("/register", response_model=AuthResponse)
async def register(
request_body: RegisterRequest,
request: Request,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Register a new user account."""
result = await auth_service.register(
username=request_body.username,
password=request_body.password,
email=request_body.email,
)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
if result.requires_verification:
# Return user info but note they need to verify
return {
"user": _user_to_response(result.user),
"token": "",
"expires_at": "",
"message": "Please check your email to verify your account",
}
# Auto-login after registration
login_result = await auth_service.login(
username=request_body.username,
password=request_body.password,
device_info=get_device_info(request),
ip_address=get_client_ip(request),
)
if not login_result.success:
raise HTTPException(status_code=500, detail="Registration succeeded but login failed")
return {
"user": _user_to_response(login_result.user),
"token": login_result.token,
"expires_at": login_result.expires_at.isoformat(),
}
@router.post("/verify-email")
async def verify_email(
request_body: VerifyEmailRequest,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Verify email address with token."""
result = await auth_service.verify_email(request_body.token)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
return {"status": "ok", "message": "Email verified successfully"}
@router.post("/resend-verification")
async def resend_verification(
request_body: ResendVerificationRequest,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Resend verification email."""
await auth_service.resend_verification(request_body.email)
# Always return success to prevent email enumeration
return {"status": "ok", "message": "If the email exists, a verification link has been sent"}
# =============================================================================
# Login/Logout Endpoints
# =============================================================================
@router.post("/login", response_model=AuthResponse)
async def login(
request_body: LoginRequest,
request: Request,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Login with username/email and password."""
result = await auth_service.login(
username=request_body.username,
password=request_body.password,
device_info=get_device_info(request),
ip_address=get_client_ip(request),
)
if not result.success:
raise HTTPException(status_code=401, detail=result.error)
return {
"user": _user_to_response(result.user),
"token": result.token,
"expires_at": result.expires_at.isoformat(),
}
@router.post("/logout")
async def logout(
token: Optional[str] = Depends(get_token_from_header),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Logout current session."""
if token:
await auth_service.logout(token)
return {"status": "ok"}
@router.post("/logout-all")
async def logout_all(
user: User = Depends(require_user_v2),
token: Optional[str] = Depends(get_token_from_header),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Logout all sessions except current."""
count = await auth_service.logout_all(user.id, except_token=token)
return {"status": "ok", "sessions_revoked": count}
# =============================================================================
# Password Management Endpoints
# =============================================================================
@router.post("/forgot-password")
async def forgot_password(
request_body: ForgotPasswordRequest,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Request password reset email."""
await auth_service.forgot_password(request_body.email)
# Always return success to prevent email enumeration
return {"status": "ok", "message": "If the email exists, a reset link has been sent"}
@router.post("/reset-password")
async def reset_password(
request_body: ResetPasswordRequest,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Reset password with token."""
result = await auth_service.reset_password(
token=request_body.token,
new_password=request_body.new_password,
)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
return {"status": "ok", "message": "Password reset successfully"}
@router.put("/password")
async def change_password(
request_body: ChangePasswordRequest,
user: User = Depends(require_user_v2),
token: Optional[str] = Depends(get_token_from_header),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Change password for current user."""
result = await auth_service.change_password(
user_id=user.id,
current_password=request_body.current_password,
new_password=request_body.new_password,
current_token=token,
)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
return {"status": "ok", "message": "Password changed successfully"}
# =============================================================================
# User Profile Endpoints
# =============================================================================
@router.get("/me")
async def get_me(user: User = Depends(require_user_v2)):
"""Get current user info."""
return {"user": _user_to_response(user)}
@router.put("/me/preferences")
async def update_preferences(
request_body: UpdatePreferencesRequest,
user: User = Depends(require_user_v2),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Update user preferences."""
updated = await auth_service.update_preferences(user.id, request_body.preferences)
if not updated:
raise HTTPException(status_code=500, detail="Failed to update preferences")
return {"user": _user_to_response(updated)}
# =============================================================================
# Session Management Endpoints
# =============================================================================
@router.get("/sessions")
async def get_sessions(
user: User = Depends(require_user_v2),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Get all active sessions for current user."""
sessions = await auth_service.get_sessions(user.id)
return {
"sessions": [
{
"id": s.id,
"device_info": s.device_info,
"ip_address": s.ip_address,
"created_at": s.created_at.isoformat() if s.created_at else None,
"last_used_at": s.last_used_at.isoformat() if s.last_used_at else None,
}
for s in sessions
]
}
@router.delete("/sessions/{session_id}")
async def revoke_session(
session_id: str,
user: User = Depends(require_user_v2),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Revoke a specific session."""
success = await auth_service.revoke_session(user.id, session_id)
if not success:
raise HTTPException(status_code=404, detail="Session not found")
return {"status": "ok"}
# =============================================================================
# Guest Conversion Endpoint
# =============================================================================
@router.post("/convert-guest", response_model=AuthResponse)
async def convert_guest(
request_body: ConvertGuestRequest,
request: Request,
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Convert guest session to full user account."""
result = await auth_service.convert_guest(
guest_id=request_body.guest_id,
username=request_body.username,
password=request_body.password,
email=request_body.email,
)
if not result.success:
raise HTTPException(status_code=400, detail=result.error)
# Auto-login after conversion
login_result = await auth_service.login(
username=request_body.username,
password=request_body.password,
device_info=get_device_info(request),
ip_address=get_client_ip(request),
)
if not login_result.success:
raise HTTPException(status_code=500, detail="Conversion succeeded but login failed")
return {
"user": _user_to_response(login_result.user),
"token": login_result.token,
"expires_at": login_result.expires_at.isoformat(),
}
# =============================================================================
# Account Deletion Endpoint
# =============================================================================
@router.delete("/me")
async def delete_account(
user: User = Depends(require_user_v2),
auth_service: AuthService = Depends(get_auth_service_dep),
):
"""Delete (soft delete) current user account."""
success = await auth_service.delete_account(user.id)
if not success:
raise HTTPException(status_code=500, detail="Failed to delete account")
return {"status": "ok", "message": "Account deleted"}
# =============================================================================
# Helpers
# =============================================================================
def _user_to_response(user: User) -> dict:
"""Convert User to response dict (public fields only)."""
return {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role.value,
"email_verified": user.email_verified,
"preferences": user.preferences,
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_login": user.last_login.isoformat() if user.last_login else None,
}

171
server/routers/health.py Normal file
View File

@@ -0,0 +1,171 @@
"""
Health check endpoints for production deployment.
Provides:
- /health - Basic liveness check (is the app running?)
- /ready - Readiness check (can the app handle requests?)
- /metrics - Application metrics for monitoring
"""
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Response
logger = logging.getLogger(__name__)
router = APIRouter(tags=["health"])
# Service references (set during app initialization)
_db_pool = None
_redis_client = None
_room_manager = None
def set_health_dependencies(
db_pool=None,
redis_client=None,
room_manager=None,
):
"""Set dependencies for health checks."""
global _db_pool, _redis_client, _room_manager
_db_pool = db_pool
_redis_client = redis_client
_room_manager = room_manager
@router.get("/health")
async def health_check():
"""
Basic liveness check - is the app running?
This endpoint should always return 200 if the process is alive.
Used by container orchestration for restart decisions.
"""
return {
"status": "ok",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
@router.get("/ready")
async def readiness_check():
"""
Readiness check - can the app handle requests?
Checks connectivity to required services (database, Redis).
Returns 503 if any critical service is unavailable.
"""
checks = {}
overall_healthy = True
# Check PostgreSQL
if _db_pool is not None:
try:
async with _db_pool.acquire() as conn:
await conn.fetchval("SELECT 1")
checks["database"] = {"status": "ok"}
except Exception as e:
logger.warning(f"Database health check failed: {e}")
checks["database"] = {"status": "error", "message": str(e)}
overall_healthy = False
else:
checks["database"] = {"status": "not_configured"}
# Check Redis
if _redis_client is not None:
try:
await _redis_client.ping()
checks["redis"] = {"status": "ok"}
except Exception as e:
logger.warning(f"Redis health check failed: {e}")
checks["redis"] = {"status": "error", "message": str(e)}
overall_healthy = False
else:
checks["redis"] = {"status": "not_configured"}
status_code = 200 if overall_healthy else 503
return Response(
content=json.dumps({
"status": "ok" if overall_healthy else "degraded",
"checks": checks,
"timestamp": datetime.now(timezone.utc).isoformat(),
}),
status_code=status_code,
media_type="application/json",
)
@router.get("/metrics")
async def metrics():
"""
Expose application metrics for monitoring.
Returns operational metrics useful for dashboards and alerting.
"""
metrics_data = {
"timestamp": datetime.now(timezone.utc).isoformat(),
}
# Room/game metrics from room manager
if _room_manager is not None:
try:
rooms = _room_manager.rooms
active_rooms = len(rooms)
total_players = sum(len(r.players) for r in rooms.values())
games_in_progress = sum(
1 for r in rooms.values()
if hasattr(r.game, 'phase') and r.game.phase.name not in ('WAITING', 'GAME_OVER')
)
metrics_data.update({
"active_rooms": active_rooms,
"total_players": total_players,
"games_in_progress": games_in_progress,
})
except Exception as e:
logger.warning(f"Failed to collect room metrics: {e}")
# Database metrics
if _db_pool is not None:
try:
async with _db_pool.acquire() as conn:
# Count active games (if games table exists)
try:
games_today = await conn.fetchval(
"SELECT COUNT(*) FROM game_events WHERE timestamp > NOW() - INTERVAL '1 day'"
)
metrics_data["events_today"] = games_today
except Exception:
pass # Table might not exist
# Count users (if users table exists)
try:
total_users = await conn.fetchval("SELECT COUNT(*) FROM users")
metrics_data["total_users"] = total_users
except Exception:
pass # Table might not exist
except Exception as e:
logger.warning(f"Failed to collect database metrics: {e}")
# Redis metrics
if _redis_client is not None:
try:
# Get connected players from Redis set if tracking
try:
connected = await _redis_client.scard("golf:connected_players")
metrics_data["connected_websockets"] = connected
except Exception:
pass
# Get active rooms from Redis
try:
active_rooms_redis = await _redis_client.scard("golf:rooms:active")
metrics_data["active_rooms_redis"] = active_rooms_redis
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to collect Redis metrics: {e}")
return metrics_data

490
server/routers/replay.py Normal file
View File

@@ -0,0 +1,490 @@
"""
Replay API router for Golf game.
Provides endpoints for:
- Viewing game replays
- Creating and managing share links
- Exporting/importing games
- Spectating live games
"""
import hashlib
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Depends, Header, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/replay", tags=["replay"])
# Service instances (set during app startup)
_replay_service = None
_auth_service = None
_spectator_manager = None
_room_manager = None
def set_replay_service(service) -> None:
"""Set the replay service instance."""
global _replay_service
_replay_service = service
def set_auth_service(service) -> None:
"""Set the auth service instance."""
global _auth_service
_auth_service = service
def set_spectator_manager(manager) -> None:
"""Set the spectator manager instance."""
global _spectator_manager
_spectator_manager = manager
def set_room_manager(manager) -> None:
"""Set the room manager instance."""
global _room_manager
_room_manager = manager
# -------------------------------------------------------------------------
# Auth Dependencies
# -------------------------------------------------------------------------
async def get_current_user(authorization: Optional[str] = Header(None)):
"""Get current user from Authorization header."""
if not authorization or not _auth_service:
return None
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
token = parts[1]
return await _auth_service.get_user_from_token(token)
async def require_auth(user=Depends(get_current_user)):
"""Require authenticated user."""
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
# -------------------------------------------------------------------------
# Request/Response Models
# -------------------------------------------------------------------------
class ShareLinkRequest(BaseModel):
"""Request to create a share link."""
title: Optional[str] = None
description: Optional[str] = None
expires_days: Optional[int] = None
class ImportGameRequest(BaseModel):
"""Request to import a game."""
export_data: dict
# -------------------------------------------------------------------------
# Replay Endpoints
# -------------------------------------------------------------------------
@router.get("/game/{game_id}")
async def get_replay(game_id: str, user=Depends(get_current_user)):
"""
Get full replay for a game.
Returns all frames with game state at each step.
Requires authentication and permission to view the game.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
# Check permission
user_id = user.id if user else None
if not await _replay_service.can_view_game(user_id, game_id):
raise HTTPException(status_code=403, detail="Cannot view this game")
try:
replay = await _replay_service.build_replay(game_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {
"game_id": replay.game_id,
"room_code": replay.room_code,
"frames": [
{
"index": f.event_index,
"event_type": f.event_type,
"event_data": f.event_data,
"timestamp": f.timestamp,
"state": f.game_state,
"player_id": f.player_id,
}
for f in replay.frames
],
"metadata": {
"players": replay.player_names,
"winner": replay.winner,
"final_scores": replay.final_scores,
"duration": replay.total_duration_seconds,
"total_rounds": replay.total_rounds,
"options": replay.options,
},
}
@router.get("/game/{game_id}/frame/{frame_index}")
async def get_replay_frame(game_id: str, frame_index: int, user=Depends(get_current_user)):
"""
Get a specific frame from a replay.
Useful for seeking without loading the entire replay.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
user_id = user.id if user else None
if not await _replay_service.can_view_game(user_id, game_id):
raise HTTPException(status_code=403, detail="Cannot view this game")
frame = await _replay_service.get_replay_frame(game_id, frame_index)
if not frame:
raise HTTPException(status_code=404, detail="Frame not found")
return {
"index": frame.event_index,
"event_type": frame.event_type,
"event_data": frame.event_data,
"timestamp": frame.timestamp,
"state": frame.game_state,
"player_id": frame.player_id,
}
# -------------------------------------------------------------------------
# Share Link Endpoints
# -------------------------------------------------------------------------
@router.post("/game/{game_id}/share")
async def create_share_link(
game_id: str,
request: ShareLinkRequest,
user=Depends(require_auth),
):
"""
Create shareable link for a game.
Only users who played in the game can create share links.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
# Validate expires_days
if request.expires_days is not None and (request.expires_days < 1 or request.expires_days > 365):
raise HTTPException(status_code=400, detail="expires_days must be between 1 and 365")
# Check if user played in the game
if not await _replay_service.can_view_game(user.id, game_id):
raise HTTPException(status_code=403, detail="Can only share games you played in")
try:
share_code = await _replay_service.create_share_link(
game_id=game_id,
user_id=user.id,
title=request.title,
description=request.description,
expires_days=request.expires_days,
)
except Exception as e:
logger.error(f"Failed to create share link: {e}")
raise HTTPException(status_code=500, detail="Failed to create share link")
return {
"share_code": share_code,
"share_url": f"/replay/{share_code}",
"expires_days": request.expires_days,
}
@router.get("/shared/{share_code}")
async def get_shared_replay(share_code: str):
"""
Get replay via share code (public endpoint).
No authentication required for public share links.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
shared = await _replay_service.get_shared_game(share_code)
if not shared:
raise HTTPException(status_code=404, detail="Shared game not found or expired")
try:
replay = await _replay_service.build_replay(str(shared["game_id"]))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {
"title": shared.get("title"),
"description": shared.get("description"),
"view_count": shared["view_count"],
"created_at": shared["created_at"].isoformat() if shared.get("created_at") else None,
"game_id": str(shared["game_id"]),
"room_code": replay.room_code,
"frames": [
{
"index": f.event_index,
"event_type": f.event_type,
"event_data": f.event_data,
"timestamp": f.timestamp,
"state": f.game_state,
"player_id": f.player_id,
}
for f in replay.frames
],
"metadata": {
"players": replay.player_names,
"winner": replay.winner,
"final_scores": replay.final_scores,
"duration": replay.total_duration_seconds,
"total_rounds": replay.total_rounds,
"options": replay.options,
},
}
@router.get("/shared/{share_code}/info")
async def get_shared_info(share_code: str):
"""
Get info about a shared game without full replay data.
Useful for preview/metadata display.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
shared = await _replay_service.get_shared_game(share_code)
if not shared:
raise HTTPException(status_code=404, detail="Shared game not found or expired")
return {
"title": shared.get("title"),
"description": shared.get("description"),
"view_count": shared["view_count"],
"created_at": shared["created_at"].isoformat() if shared.get("created_at") else None,
"room_code": shared.get("room_code"),
"num_players": shared.get("num_players"),
"num_rounds": shared.get("num_rounds"),
}
@router.delete("/shared/{share_code}")
async def delete_share_link(share_code: str, user=Depends(require_auth)):
"""Delete a share link (creator only)."""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
deleted = await _replay_service.delete_share_link(share_code, user.id)
if not deleted:
raise HTTPException(status_code=404, detail="Share link not found or not authorized")
return {"deleted": True}
@router.get("/my-shares")
async def get_my_shares(user=Depends(require_auth)):
"""Get all share links created by the current user."""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
shares = await _replay_service.get_user_shared_games(user.id)
return {
"shares": [
{
"share_code": s["share_code"],
"game_id": str(s["game_id"]),
"title": s.get("title"),
"view_count": s["view_count"],
"created_at": s["created_at"].isoformat() if s.get("created_at") else None,
"expires_at": s["expires_at"].isoformat() if s.get("expires_at") else None,
}
for s in shares
],
}
# -------------------------------------------------------------------------
# Export/Import Endpoints
# -------------------------------------------------------------------------
@router.get("/game/{game_id}/export")
async def export_game(game_id: str, user=Depends(require_auth)):
"""
Export game as downloadable JSON.
Returns the complete game data suitable for backup or sharing.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
if not await _replay_service.can_view_game(user.id, game_id):
raise HTTPException(status_code=403, detail="Cannot export this game")
try:
export_data = await _replay_service.export_game(game_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# Return as downloadable JSON
return JSONResponse(
content=export_data,
headers={
"Content-Disposition": f'attachment; filename="golf-game-{game_id[:8]}.json"'
},
)
@router.post("/import")
async def import_game(request: ImportGameRequest, user=Depends(require_auth)):
"""
Import a game from JSON export.
Creates a new game record from the exported data.
"""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
try:
new_game_id = await _replay_service.import_game(request.export_data, user.id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Import failed: {e}")
raise HTTPException(status_code=500, detail="Failed to import game")
return {
"game_id": new_game_id,
"message": "Game imported successfully",
}
# -------------------------------------------------------------------------
# Game History
# -------------------------------------------------------------------------
@router.get("/history")
async def get_game_history(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
user=Depends(require_auth),
):
"""Get game history for the current user."""
if not _replay_service:
raise HTTPException(status_code=503, detail="Replay service unavailable")
games = await _replay_service.get_user_game_history(user.id, limit, offset)
return {
"games": [
{
"game_id": str(g["id"]),
"room_code": g["room_code"],
"status": g["status"],
"completed_at": g["completed_at"].isoformat() if g.get("completed_at") else None,
"num_players": g["num_players"],
"num_rounds": g["num_rounds"],
"won": g.get("winner_id") == user.id,
}
for g in games
],
"limit": limit,
"offset": offset,
}
# -------------------------------------------------------------------------
# Spectator Endpoints
# -------------------------------------------------------------------------
@router.websocket("/spectate/{room_code}")
async def spectate_game(websocket: WebSocket, room_code: str):
"""
WebSocket endpoint for spectating live games.
Spectators receive real-time game state updates but cannot interact.
"""
await websocket.accept()
if not _spectator_manager or not _room_manager:
await websocket.close(code=4003, reason="Spectator service unavailable")
return
# Find the game by room code
room = _room_manager.get_room(room_code.upper())
if not room:
await websocket.close(code=4004, reason="Game not found")
return
game_id = room_code.upper() # Use room code as identifier for spectators
# Add spectator
added = await _spectator_manager.add_spectator(game_id, websocket)
if not added:
await websocket.close(code=4005, reason="Spectator limit reached")
return
try:
# Send initial game state
game_state = room.game.get_state(None) # No player perspective
await websocket.send_json({
"type": "spectator_joined",
"game_state": game_state,
"spectator_count": _spectator_manager.get_spectator_count(game_id),
"players": room.player_list(),
})
# Keep connection alive
while True:
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug(f"Spectator connection error: {e}")
finally:
await _spectator_manager.remove_spectator(game_id, websocket)
@router.get("/spectate/{room_code}/count")
async def get_spectator_count(room_code: str):
"""Get the number of spectators for a game."""
if not _spectator_manager:
return {"count": 0}
count = _spectator_manager.get_spectator_count(room_code.upper())
return {"count": count}
@router.get("/spectate/active")
async def get_active_spectated_games():
"""Get list of games with active spectators."""
if not _spectator_manager:
return {"games": []}
games = _spectator_manager.get_games_with_spectators()
return {
"games": [
{"room_code": game_id, "spectator_count": count}
for game_id, count in games.items()
],
}

385
server/routers/stats.py Normal file
View File

@@ -0,0 +1,385 @@
"""
Stats and Leaderboards API router for Golf game.
Provides public endpoints for viewing leaderboards and player stats,
and authenticated endpoints for viewing personal stats and achievements.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Query
from pydantic import BaseModel
from models.user import User
from services.stats_service import StatsService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/stats", tags=["stats"])
# =============================================================================
# Request/Response Models
# =============================================================================
class LeaderboardEntryResponse(BaseModel):
"""Single leaderboard entry."""
rank: int
user_id: str
username: str
value: float
games_played: int
secondary_value: Optional[float] = None
class LeaderboardResponse(BaseModel):
"""Leaderboard response."""
metric: str
entries: list[LeaderboardEntryResponse]
total_players: Optional[int] = None
class PlayerStatsResponse(BaseModel):
"""Player statistics response."""
user_id: str
username: str
games_played: int
games_won: int
win_rate: float
rounds_played: int
rounds_won: int
avg_score: float
best_round_score: Optional[int]
worst_round_score: Optional[int]
knockouts: int
perfect_rounds: int
wolfpacks: int
current_win_streak: int
best_win_streak: int
first_game_at: Optional[str]
last_game_at: Optional[str]
achievements: list[str]
class PlayerRankResponse(BaseModel):
"""Player rank response."""
user_id: str
metric: str
rank: Optional[int]
qualified: bool # Whether player has enough games
class AchievementResponse(BaseModel):
"""Achievement definition response."""
id: str
name: str
description: str
icon: str
category: str
threshold: int
class UserAchievementResponse(BaseModel):
"""User achievement response."""
id: str
name: str
description: str
icon: str
earned_at: str
game_id: Optional[str]
# =============================================================================
# Dependencies
# =============================================================================
# Set by main.py during startup
_stats_service: Optional[StatsService] = None
def set_stats_service(service: StatsService) -> None:
"""Set the stats service instance (called from main.py)."""
global _stats_service
_stats_service = service
def get_stats_service_dep() -> StatsService:
"""Dependency to get stats service."""
if _stats_service is None:
raise HTTPException(status_code=503, detail="Stats service not initialized")
return _stats_service
# Auth dependencies - imported from auth router
_auth_service = None
def set_auth_service(service) -> None:
"""Set auth service for user lookup."""
global _auth_service
_auth_service = service
async def get_current_user_optional(
authorization: Optional[str] = Header(None),
) -> Optional[User]:
"""Get current user from Authorization header (optional)."""
if not authorization or not _auth_service:
return None
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
token = parts[1]
return await _auth_service.get_user_from_token(token)
async def require_user(
user: Optional[User] = Depends(get_current_user_optional),
) -> User:
"""Require authenticated user."""
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account disabled")
return user
# =============================================================================
# Public Endpoints (No Auth Required)
# =============================================================================
@router.get("/leaderboard", response_model=LeaderboardResponse)
async def get_leaderboard(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
service: StatsService = Depends(get_stats_service_dep),
):
"""
Get leaderboard by metric.
Metrics:
- wins: Total games won
- win_rate: Win percentage (requires 5+ games)
- avg_score: Average points per round (lower is better)
- knockouts: Times going out first
- streak: Best win streak
Players must have 5+ games to appear on leaderboards.
"""
entries = await service.get_leaderboard(metric, limit, offset)
return {
"metric": metric,
"entries": [
{
"rank": e.rank,
"user_id": e.user_id,
"username": e.username,
"value": e.value,
"games_played": e.games_played,
"secondary_value": e.secondary_value,
}
for e in entries
],
}
@router.get("/players/{user_id}", response_model=PlayerStatsResponse)
async def get_player_stats(
user_id: str,
service: StatsService = Depends(get_stats_service_dep),
):
"""Get stats for a specific player (public profile)."""
stats = await service.get_player_stats(user_id)
if not stats:
raise HTTPException(status_code=404, detail="Player not found")
return {
"user_id": stats.user_id,
"username": stats.username,
"games_played": stats.games_played,
"games_won": stats.games_won,
"win_rate": stats.win_rate,
"rounds_played": stats.rounds_played,
"rounds_won": stats.rounds_won,
"avg_score": stats.avg_score,
"best_round_score": stats.best_round_score,
"worst_round_score": stats.worst_round_score,
"knockouts": stats.knockouts,
"perfect_rounds": stats.perfect_rounds,
"wolfpacks": stats.wolfpacks,
"current_win_streak": stats.current_win_streak,
"best_win_streak": stats.best_win_streak,
"first_game_at": stats.first_game_at.isoformat() if stats.first_game_at else None,
"last_game_at": stats.last_game_at.isoformat() if stats.last_game_at else None,
"achievements": stats.achievements,
}
@router.get("/players/{user_id}/rank", response_model=PlayerRankResponse)
async def get_player_rank(
user_id: str,
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
service: StatsService = Depends(get_stats_service_dep),
):
"""Get player's rank on a leaderboard."""
rank = await service.get_player_rank(user_id, metric)
return {
"user_id": user_id,
"metric": metric,
"rank": rank,
"qualified": rank is not None,
}
@router.get("/achievements", response_model=dict)
async def get_achievements(
service: StatsService = Depends(get_stats_service_dep),
):
"""Get all available achievements."""
achievements = await service.get_achievements()
return {
"achievements": [
{
"id": a.id,
"name": a.name,
"description": a.description,
"icon": a.icon,
"category": a.category,
"threshold": a.threshold,
}
for a in achievements
]
}
@router.get("/players/{user_id}/achievements", response_model=dict)
async def get_user_achievements(
user_id: str,
service: StatsService = Depends(get_stats_service_dep),
):
"""Get achievements earned by a player."""
achievements = await service.get_user_achievements(user_id)
return {
"user_id": user_id,
"achievements": [
{
"id": a.id,
"name": a.name,
"description": a.description,
"icon": a.icon,
"earned_at": a.earned_at.isoformat(),
"game_id": a.game_id,
}
for a in achievements
],
}
# =============================================================================
# Authenticated Endpoints
# =============================================================================
@router.get("/me", response_model=PlayerStatsResponse)
async def get_my_stats(
user: User = Depends(require_user),
service: StatsService = Depends(get_stats_service_dep),
):
"""Get current user's stats."""
stats = await service.get_player_stats(user.id)
if not stats:
# Return empty stats for new user
return {
"user_id": user.id,
"username": user.username,
"games_played": 0,
"games_won": 0,
"win_rate": 0.0,
"rounds_played": 0,
"rounds_won": 0,
"avg_score": 0.0,
"best_round_score": None,
"worst_round_score": None,
"knockouts": 0,
"perfect_rounds": 0,
"wolfpacks": 0,
"current_win_streak": 0,
"best_win_streak": 0,
"first_game_at": None,
"last_game_at": None,
"achievements": [],
}
return {
"user_id": stats.user_id,
"username": stats.username,
"games_played": stats.games_played,
"games_won": stats.games_won,
"win_rate": stats.win_rate,
"rounds_played": stats.rounds_played,
"rounds_won": stats.rounds_won,
"avg_score": stats.avg_score,
"best_round_score": stats.best_round_score,
"worst_round_score": stats.worst_round_score,
"knockouts": stats.knockouts,
"perfect_rounds": stats.perfect_rounds,
"wolfpacks": stats.wolfpacks,
"current_win_streak": stats.current_win_streak,
"best_win_streak": stats.best_win_streak,
"first_game_at": stats.first_game_at.isoformat() if stats.first_game_at else None,
"last_game_at": stats.last_game_at.isoformat() if stats.last_game_at else None,
"achievements": stats.achievements,
}
@router.get("/me/rank", response_model=PlayerRankResponse)
async def get_my_rank(
metric: str = Query("wins", pattern="^(wins|win_rate|avg_score|knockouts|streak)$"),
user: User = Depends(require_user),
service: StatsService = Depends(get_stats_service_dep),
):
"""Get current user's rank on a leaderboard."""
rank = await service.get_player_rank(user.id, metric)
return {
"user_id": user.id,
"metric": metric,
"rank": rank,
"qualified": rank is not None,
}
@router.get("/me/achievements", response_model=dict)
async def get_my_achievements(
user: User = Depends(require_user),
service: StatsService = Depends(get_stats_service_dep),
):
"""Get current user's achievements."""
achievements = await service.get_user_achievements(user.id)
return {
"user_id": user.id,
"achievements": [
{
"id": a.id,
"name": a.name,
"description": a.description,
"icon": a.icon,
"earned_at": a.earned_at.isoformat(),
"game_id": a.game_id,
}
for a in achievements
],
}