420 lines
12 KiB
Python
420 lines
12 KiB
Python
"""
|
|
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"}
|