Added admin panel.

This commit is contained in:
2026-01-11 18:50:26 -06:00
parent e50b2f31d3
commit a97912188e
109 changed files with 6651 additions and 249 deletions

View File

@ -1,19 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from typing import List
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from typing import Optional
from datetime import datetime
from decimal import Decimal
from app.database import get_db
from app.models import User, SportEvent, SpreadBet, AdminSettings, EventStatus
from app.models import (
User, UserStatus, SportEvent, SpreadBet, AdminSettings, EventStatus,
Wallet, Transaction, TransactionType, TransactionStatus, AdminAuditLog, Bet
)
from app.schemas.sport_event import SportEventCreate, SportEventUpdate, SportEvent as SportEventSchema
from app.schemas.admin import (
AuditLogResponse, AuditLogListResponse,
WipePreviewResponse, WipeRequest, WipeResponse,
SeedRequest, SeedResponse,
SimulationConfig, SimulationStatusResponse, SimulationStartRequest, SimulationStartResponse, SimulationStopResponse,
AdminUserListItem, AdminUserListResponse, AdminUserDetailResponse,
AdminUserUpdateRequest, AdminUserStatusRequest,
AdminBalanceAdjustRequest, AdminBalanceAdjustResponse,
AdminDashboardStats,
)
from app.routers.auth import get_current_user
from app.services.audit_service import (
AuditService, log_event_create, log_event_update, log_event_delete,
log_user_status_change, log_user_balance_adjust, log_user_admin_change,
log_user_update, log_settings_update, log_simulation_start, log_simulation_stop
)
from app.services.wiper_service import WiperService
from app.services.seeder_service import SeederService
from app.services.simulation_service import simulation_manager
router = APIRouter(prefix="/api/v1/admin", tags=["admin"])
# Dependency to check if user is admin
# ============================================================
# Helper to get client IP
# ============================================================
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()
return request.client.host if request.client else None
# ============================================================
# Admin Dependency
# ============================================================
async def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_admin:
raise HTTPException(
@ -23,6 +58,506 @@ async def get_admin_user(current_user: User = Depends(get_current_user)) -> User
return current_user
# ============================================================
# Dashboard Stats
# ============================================================
@router.get("/dashboard", response_model=AdminDashboardStats)
async def get_dashboard_stats(
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
"""Get dashboard statistics for admin panel."""
# User counts
total_users = (await db.execute(select(func.count(User.id)))).scalar() or 0
active_users = (await db.execute(
select(func.count(User.id)).where(User.status == UserStatus.ACTIVE)
)).scalar() or 0
suspended_users = (await db.execute(
select(func.count(User.id)).where(User.status == UserStatus.SUSPENDED)
)).scalar() or 0
admin_users = (await db.execute(
select(func.count(User.id)).where(User.is_admin == True)
)).scalar() or 0
# Event counts
total_events = (await db.execute(select(func.count(SportEvent.id)))).scalar() or 0
upcoming_events = (await db.execute(
select(func.count(SportEvent.id)).where(SportEvent.status == EventStatus.UPCOMING)
)).scalar() or 0
live_events = (await db.execute(
select(func.count(SportEvent.id)).where(SportEvent.status == EventStatus.LIVE)
)).scalar() or 0
# Bet counts
total_bets = (await db.execute(select(func.count(SpreadBet.id)))).scalar() or 0
open_bets = (await db.execute(
select(func.count(SpreadBet.id)).where(SpreadBet.status == "open")
)).scalar() or 0
matched_bets = (await db.execute(
select(func.count(SpreadBet.id)).where(SpreadBet.status == "matched")
)).scalar() or 0
# Volume calculations
total_volume_result = await db.execute(
select(func.sum(SpreadBet.stake_amount))
)
total_volume = total_volume_result.scalar() or Decimal("0.00")
escrow_result = await db.execute(select(func.sum(Wallet.escrow)))
escrow_locked = escrow_result.scalar() or Decimal("0.00")
return AdminDashboardStats(
total_users=total_users,
active_users=active_users,
suspended_users=suspended_users,
admin_users=admin_users,
total_events=total_events,
upcoming_events=upcoming_events,
live_events=live_events,
total_bets=total_bets,
open_bets=open_bets,
matched_bets=matched_bets,
total_volume=total_volume,
escrow_locked=escrow_locked,
simulation_running=simulation_manager.is_running,
)
# ============================================================
# Audit Logs
# ============================================================
@router.get("/audit-logs", response_model=AuditLogListResponse)
async def get_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
action: Optional[str] = None,
admin_id: Optional[int] = None,
target_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
"""Get paginated audit logs with optional filters."""
logs, total = await AuditService.get_logs(
db=db,
page=page,
page_size=page_size,
action_filter=action,
admin_id_filter=admin_id,
target_type_filter=target_type,
)
return AuditLogListResponse(
logs=[AuditLogResponse.model_validate(log) for log in logs],
total=total,
page=page,
page_size=page_size,
)
# ============================================================
# Data Wiper
# ============================================================
@router.get("/data/wipe/preview", response_model=WipePreviewResponse)
async def preview_wipe(
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
"""Preview what will be deleted in a wipe operation."""
return await WiperService.get_preview(db)
@router.post("/data/wipe", response_model=WipeResponse)
async def execute_wipe(
request: WipeRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Execute a database wipe. Requires confirmation phrase."""
try:
ip = get_client_ip(req) if req else None
return await WiperService.execute_wipe(db, admin, request, ip)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ============================================================
# Data Seeder
# ============================================================
@router.post("/data/seed", response_model=SeedResponse)
async def seed_database(
request: SeedRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Seed the database with test data."""
ip = get_client_ip(req) if req else None
return await SeederService.seed(db, admin, request, ip)
# ============================================================
# Simulation Control
# ============================================================
@router.get("/simulation/status", response_model=SimulationStatusResponse)
async def get_simulation_status(
admin: User = Depends(get_admin_user)
):
"""Get current simulation status."""
return simulation_manager.get_status()
@router.post("/simulation/start", response_model=SimulationStartResponse)
async def start_simulation(
request: SimulationStartRequest = None,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Start the activity simulation."""
config = request.config if request else None
if simulation_manager.is_running:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Simulation is already running"
)
success = await simulation_manager.start(admin.username, config)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to start simulation"
)
# Log the action
ip = get_client_ip(req) if req else None
await log_simulation_start(
db=db,
admin=admin,
config=config.model_dump() if config else {},
ip_address=ip,
)
await db.commit()
return SimulationStartResponse(
success=True,
message="Simulation started successfully",
status=simulation_manager.get_status(),
)
@router.post("/simulation/stop", response_model=SimulationStopResponse)
async def stop_simulation(
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Stop the activity simulation."""
if not simulation_manager.is_running:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Simulation is not running"
)
iterations, duration = await simulation_manager.stop()
# Log the action
ip = get_client_ip(req) if req else None
await log_simulation_stop(
db=db,
admin=admin,
iterations=iterations,
duration_seconds=duration,
ip_address=ip,
)
await db.commit()
return SimulationStopResponse(
success=True,
message="Simulation stopped successfully",
total_iterations=iterations,
ran_for_seconds=duration,
)
# ============================================================
# User Management
# ============================================================
@router.get("/users", response_model=AdminUserListResponse)
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: Optional[str] = None,
status_filter: Optional[str] = None,
is_admin: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
"""Get paginated list of users."""
query = select(User).options(selectinload(User.wallet))
# Apply filters
if search:
search_term = f"%{search}%"
query = query.where(
(User.username.ilike(search_term)) |
(User.email.ilike(search_term)) |
(User.display_name.ilike(search_term))
)
if status_filter:
query = query.where(User.status == UserStatus(status_filter))
if is_admin is not None:
query = query.where(User.is_admin == is_admin)
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar() or 0
# Apply pagination
query = query.order_by(User.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
users = result.scalars().all()
user_items = []
for user in users:
wallet = user.wallet
user_items.append(AdminUserListItem(
id=user.id,
email=user.email,
username=user.username,
display_name=user.display_name,
is_admin=user.is_admin,
status=user.status.value,
balance=wallet.balance if wallet else Decimal("0.00"),
escrow=wallet.escrow if wallet else Decimal("0.00"),
total_bets=user.total_bets,
wins=user.wins,
losses=user.losses,
win_rate=user.win_rate,
created_at=user.created_at,
))
return AdminUserListResponse(
users=user_items,
total=total,
page=page,
page_size=page_size,
)
@router.get("/users/{user_id}", response_model=AdminUserDetailResponse)
async def get_user_detail(
user_id: int,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
):
"""Get detailed user information."""
result = await db.execute(
select(User).options(selectinload(User.wallet)).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
wallet = user.wallet
# Get additional counts
open_bets = (await db.execute(
select(func.count(SpreadBet.id)).where(
(SpreadBet.creator_id == user_id) & (SpreadBet.status == "open")
)
)).scalar() or 0
matched_bets = (await db.execute(
select(func.count(SpreadBet.id)).where(
((SpreadBet.creator_id == user_id) | (SpreadBet.taker_id == user_id)) &
(SpreadBet.status == "matched")
)
)).scalar() or 0
transaction_count = (await db.execute(
select(func.count(Transaction.id)).where(Transaction.user_id == user_id)
)).scalar() or 0
return AdminUserDetailResponse(
id=user.id,
email=user.email,
username=user.username,
display_name=user.display_name,
avatar_url=user.avatar_url,
bio=user.bio,
is_admin=user.is_admin,
status=user.status.value,
created_at=user.created_at,
updated_at=user.updated_at,
balance=wallet.balance if wallet else Decimal("0.00"),
escrow=wallet.escrow if wallet else Decimal("0.00"),
total_bets=user.total_bets,
wins=user.wins,
losses=user.losses,
win_rate=user.win_rate,
open_bets_count=open_bets,
matched_bets_count=matched_bets,
transaction_count=transaction_count,
)
@router.patch("/users/{user_id}")
async def update_user(
user_id: int,
request: AdminUserUpdateRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Update user details."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
changes = {}
ip = get_client_ip(req) if req else None
if request.display_name is not None and request.display_name != user.display_name:
changes["display_name"] = {"old": user.display_name, "new": request.display_name}
user.display_name = request.display_name
if request.email is not None and request.email != user.email:
# Check if email already exists
existing = await db.execute(select(User).where(User.email == request.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already in use")
changes["email"] = {"old": user.email, "new": request.email}
user.email = request.email
if request.is_admin is not None and request.is_admin != user.is_admin:
# Cannot remove own admin status
if user.id == admin.id and not request.is_admin:
raise HTTPException(status_code=400, detail="Cannot remove your own admin privileges")
await log_user_admin_change(db, admin, user, request.is_admin, ip)
changes["is_admin"] = {"old": user.is_admin, "new": request.is_admin}
user.is_admin = request.is_admin
if changes:
await log_user_update(db, admin, user, changes, ip)
await db.commit()
return {"message": "User updated successfully", "changes": changes}
@router.patch("/users/{user_id}/status")
async def change_user_status(
user_id: int,
request: AdminUserStatusRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Enable or disable a user."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Cannot suspend yourself
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot change your own status")
old_status = user.status.value
new_status = UserStatus(request.status)
if user.status == new_status:
return {"message": "Status unchanged"}
user.status = new_status
ip = get_client_ip(req) if req else None
await log_user_status_change(db, admin, user, old_status, request.status, request.reason, ip)
await db.commit()
return {"message": f"User status changed to {request.status}"}
@router.post("/users/{user_id}/balance", response_model=AdminBalanceAdjustResponse)
async def adjust_user_balance(
user_id: int,
request: AdminBalanceAdjustRequest,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user),
req: Request = None,
):
"""Adjust user balance (add or subtract funds)."""
result = await db.execute(
select(User).options(selectinload(User.wallet)).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
wallet = user.wallet
if not wallet:
raise HTTPException(status_code=400, detail="User has no wallet")
previous_balance = wallet.balance
new_balance = previous_balance + request.amount
# Validate new balance
if new_balance < Decimal("0.00"):
raise HTTPException(
status_code=400,
detail=f"Cannot reduce balance below $0. Current: ${previous_balance}, Adjustment: ${request.amount}"
)
if new_balance < wallet.escrow:
raise HTTPException(
status_code=400,
detail=f"Cannot reduce balance below escrow amount (${wallet.escrow})"
)
wallet.balance = new_balance
# Create transaction record
tx_type = TransactionType.ADMIN_CREDIT if request.amount > 0 else TransactionType.ADMIN_DEBIT
transaction = Transaction(
user_id=user.id,
wallet_id=wallet.id,
type=tx_type,
amount=request.amount,
balance_after=new_balance,
description=f"Admin adjustment: {request.reason}",
status=TransactionStatus.COMPLETED,
)
db.add(transaction)
await db.flush()
ip = get_client_ip(req) if req else None
await log_user_balance_adjust(
db, admin, user,
float(previous_balance), float(new_balance), float(request.amount),
request.reason, transaction.id, ip
)
await db.commit()
return AdminBalanceAdjustResponse(
success=True,
user_id=user.id,
username=user.username,
previous_balance=previous_balance,
adjustment=request.amount,
new_balance=new_balance,
reason=request.reason,
transaction_id=transaction.id,
)
# ============================================================
# Settings Management (existing endpoints, enhanced with audit)
# ============================================================
@router.get("/settings")
async def get_admin_settings(
db: AsyncSession = Depends(get_db),
@ -42,7 +577,8 @@ async def get_admin_settings(
async def update_admin_settings(
updates: dict,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
admin: User = Depends(get_admin_user),
req: Request = None,
):
result = await db.execute(select(AdminSettings).limit(1))
settings = result.scalar_one_or_none()
@ -50,32 +586,49 @@ async def update_admin_settings(
settings = AdminSettings()
db.add(settings)
changes = {}
for key, value in updates.items():
if hasattr(settings, key):
setattr(settings, key, value)
old_value = getattr(settings, key)
if old_value != value:
changes[key] = {"old": str(old_value), "new": str(value)}
setattr(settings, key, value)
if changes:
ip = get_client_ip(req) if req else None
await log_settings_update(db, admin, changes, ip)
await db.commit()
await db.refresh(settings)
return settings
# ============================================================
# Event Management (existing endpoints, enhanced with audit)
# ============================================================
@router.post("/events", response_model=SportEventSchema)
async def create_event(
event_data: SportEventCreate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
admin: User = Depends(get_admin_user),
req: Request = None,
):
event = SportEvent(
**event_data.model_dump(),
created_by=admin.id
)
db.add(event)
await db.flush()
ip = get_client_ip(req) if req else None
await log_event_create(db, admin, event.id, f"{event.home_team} vs {event.away_team}", ip)
await db.commit()
await db.refresh(event)
return event
@router.get("/events", response_model=List[SportEventSchema])
@router.get("/events", response_model=list[SportEventSchema])
async def list_events(
skip: int = 0,
limit: int = 50,
@ -93,7 +646,8 @@ async def update_event(
event_id: int,
updates: SportEventUpdate,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
admin: User = Depends(get_admin_user),
req: Request = None,
):
result = await db.execute(select(SportEvent).where(SportEvent.id == event_id))
event = result.scalar_one_or_none()
@ -101,8 +655,16 @@ async def update_event(
raise HTTPException(status_code=404, detail="Event not found")
update_data = updates.model_dump(exclude_unset=True)
changes = {}
for key, value in update_data.items():
setattr(event, key, value)
old_value = getattr(event, key)
if old_value != value:
changes[key] = {"old": str(old_value), "new": str(value)}
setattr(event, key, value)
if changes:
ip = get_client_ip(req) if req else None
await log_event_update(db, admin, event.id, f"{event.home_team} vs {event.away_team}", changes, ip)
await db.commit()
await db.refresh(event)
@ -113,7 +675,8 @@ async def update_event(
async def delete_event(
event_id: int,
db: AsyncSession = Depends(get_db),
admin: User = Depends(get_admin_user)
admin: User = Depends(get_admin_user),
req: Request = None,
):
result = await db.execute(select(SportEvent).where(SportEvent.id == event_id))
event = result.scalar_one_or_none()
@ -133,6 +696,11 @@ async def delete_event(
detail="Cannot delete event with matched bets"
)
event_title = f"{event.home_team} vs {event.away_team}"
ip = get_client_ip(req) if req else None
await log_event_delete(db, admin, event_id, event_title, ip)
await db.delete(event)
await db.commit()
return {"message": "Event deleted"}