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:
9
server/routers/__init__.py
Normal file
9
server/routers/__init__.py
Normal 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
419
server/routers/admin.py
Normal 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
506
server/routers/auth.py
Normal 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
171
server/routers/health.py
Normal 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
490
server/routers/replay.py
Normal 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
385
server/routers/stats.py
Normal 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
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user