Added admin panel.
This commit is contained in:
370
backend/app/services/audit_service.py
Normal file
370
backend/app/services/audit_service.py
Normal file
@ -0,0 +1,370 @@
|
||||
"""
|
||||
Audit Service for logging admin actions.
|
||||
All admin operations should call this service to create audit trails.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.models.admin_audit_log import AdminAuditLog
|
||||
from app.models import User
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""Service for creating and querying audit logs."""
|
||||
|
||||
@staticmethod
|
||||
async def log(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
action: str,
|
||||
description: str,
|
||||
target_type: Optional[str] = None,
|
||||
target_id: Optional[int] = None,
|
||||
details: Optional[dict[str, Any]] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""
|
||||
Create an audit log entry.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
admin: The admin user performing the action
|
||||
action: Action code (e.g., DATA_WIPE, USER_UPDATE)
|
||||
description: Human-readable description
|
||||
target_type: Type of target (user, event, bet, etc.)
|
||||
target_id: ID of the target entity
|
||||
details: Additional details as a dictionary
|
||||
ip_address: IP address of the admin
|
||||
|
||||
Returns:
|
||||
The created audit log entry
|
||||
"""
|
||||
log_entry = AdminAuditLog(
|
||||
admin_id=admin.id,
|
||||
admin_username=admin.username,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
description=description,
|
||||
details=json.dumps(details) if details else None,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
db.add(log_entry)
|
||||
await db.flush()
|
||||
return log_entry
|
||||
|
||||
@staticmethod
|
||||
async def get_logs(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
action_filter: Optional[str] = None,
|
||||
admin_id_filter: Optional[int] = None,
|
||||
target_type_filter: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
) -> tuple[list[AdminAuditLog], int]:
|
||||
"""
|
||||
Get paginated audit logs with optional filters.
|
||||
|
||||
Returns:
|
||||
Tuple of (logs, total_count)
|
||||
"""
|
||||
query = select(AdminAuditLog)
|
||||
|
||||
# Apply filters
|
||||
if action_filter:
|
||||
query = query.where(AdminAuditLog.action == action_filter)
|
||||
if admin_id_filter:
|
||||
query = query.where(AdminAuditLog.admin_id == admin_id_filter)
|
||||
if target_type_filter:
|
||||
query = query.where(AdminAuditLog.target_type == target_type_filter)
|
||||
if start_date:
|
||||
query = query.where(AdminAuditLog.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.where(AdminAuditLog.created_at <= end_date)
|
||||
|
||||
# Get total count
|
||||
count_query = select(func.count()).select_from(query.subquery())
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Apply pagination and ordering
|
||||
query = query.order_by(AdminAuditLog.created_at.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await db.execute(query)
|
||||
logs = list(result.scalars().all())
|
||||
|
||||
return logs, total
|
||||
|
||||
@staticmethod
|
||||
async def get_latest_action(
|
||||
db: AsyncSession,
|
||||
action: str,
|
||||
) -> Optional[AdminAuditLog]:
|
||||
"""Get the most recent log entry for a specific action."""
|
||||
query = (
|
||||
select(AdminAuditLog)
|
||||
.where(AdminAuditLog.action == action)
|
||||
.order_by(AdminAuditLog.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# Convenience functions for common audit actions
|
||||
|
||||
async def log_data_wipe(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
deleted_counts: dict[str, int],
|
||||
preserved_counts: dict[str, int],
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log a data wipe operation."""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action="DATA_WIPE",
|
||||
description=f"Database wiped by {admin.username}",
|
||||
details={
|
||||
"deleted": deleted_counts,
|
||||
"preserved": preserved_counts,
|
||||
},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def log_data_seed(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
created_counts: dict[str, int],
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log a data seed operation."""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action="DATA_SEED",
|
||||
description=f"Database seeded by {admin.username}",
|
||||
details={"created": created_counts},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def log_simulation_start(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
config: dict,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log simulation start."""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action="SIMULATION_START",
|
||||
description=f"Simulation started by {admin.username}",
|
||||
details={"config": config},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def log_simulation_stop(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
iterations: int,
|
||||
duration_seconds: float,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log simulation stop."""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action="SIMULATION_STOP",
|
||||
description=f"Simulation stopped by {admin.username} after {iterations} iterations",
|
||||
details={
|
||||
"iterations": iterations,
|
||||
"duration_seconds": duration_seconds,
|
||||
},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def log_user_status_change(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
target_user: User,
|
||||
old_status: str,
|
||||
new_status: str,
|
||||
reason: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log user status change."""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action="USER_STATUS_CHANGE",
|
||||
description=f"{admin.username} changed {target_user.username}'s status from {old_status} to {new_status}",
|
||||
target_type="user",
|
||||
target_id=target_user.id,
|
||||
details={
|
||||
"old_status": old_status,
|
||||
"new_status": new_status,
|
||||
"reason": reason,
|
||||
},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def log_user_balance_adjust(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
target_user: User,
|
||||
old_balance: float,
|
||||
new_balance: float,
|
||||
amount: float,
|
||||
reason: str,
|
||||
transaction_id: int,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log user balance adjustment."""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action="USER_BALANCE_ADJUST",
|
||||
description=f"{admin.username} adjusted {target_user.username}'s balance by ${amount:+.2f}",
|
||||
target_type="user",
|
||||
target_id=target_user.id,
|
||||
details={
|
||||
"old_balance": old_balance,
|
||||
"new_balance": new_balance,
|
||||
"amount": amount,
|
||||
"reason": reason,
|
||||
"transaction_id": transaction_id,
|
||||
},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def log_user_admin_change(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
target_user: User,
|
||||
granted: bool,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log admin privilege grant/revoke."""
|
||||
action = "USER_ADMIN_GRANT" if granted else "USER_ADMIN_REVOKE"
|
||||
verb = "granted admin privileges to" if granted else "revoked admin privileges from"
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action=action,
|
||||
description=f"{admin.username} {verb} {target_user.username}",
|
||||
target_type="user",
|
||||
target_id=target_user.id,
|
||||
details={"granted": granted},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def log_user_update(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
target_user: User,
|
||||
changes: dict[str, Any],
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log user details update."""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action="USER_UPDATE",
|
||||
description=f"{admin.username} updated {target_user.username}'s profile",
|
||||
target_type="user",
|
||||
target_id=target_user.id,
|
||||
details={"changes": changes},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def log_settings_update(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
changes: dict[str, Any],
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log platform settings update."""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action="SETTINGS_UPDATE",
|
||||
description=f"{admin.username} updated platform settings",
|
||||
details={"changes": changes},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def log_event_create(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
event_id: int,
|
||||
event_title: str,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log sport event creation."""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action="EVENT_CREATE",
|
||||
description=f"{admin.username} created event: {event_title}",
|
||||
target_type="event",
|
||||
target_id=event_id,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def log_event_update(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
event_id: int,
|
||||
event_title: str,
|
||||
changes: dict[str, Any],
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log sport event update."""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action="EVENT_UPDATE",
|
||||
description=f"{admin.username} updated event: {event_title}",
|
||||
target_type="event",
|
||||
target_id=event_id,
|
||||
details={"changes": changes},
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def log_event_delete(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
event_id: int,
|
||||
event_title: str,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> AdminAuditLog:
|
||||
"""Log sport event deletion."""
|
||||
return await AuditService.log(
|
||||
db=db,
|
||||
admin=admin,
|
||||
action="EVENT_DELETE",
|
||||
description=f"{admin.username} deleted event: {event_title}",
|
||||
target_type="event",
|
||||
target_id=event_id,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
262
backend/app/services/seeder_service.py
Normal file
262
backend/app/services/seeder_service.py
Normal file
@ -0,0 +1,262 @@
|
||||
"""
|
||||
Data Seeder Service for populating the database with test data.
|
||||
Can be controlled via API for admin-initiated seeding.
|
||||
"""
|
||||
|
||||
import random
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import (
|
||||
User, Wallet, SportEvent, SpreadBet, EventComment,
|
||||
SportType, EventStatus, SpreadBetStatus, TeamSide
|
||||
)
|
||||
from app.schemas.admin import SeedRequest, SeedResponse
|
||||
from app.services.audit_service import log_data_seed
|
||||
from app.utils.security import get_password_hash
|
||||
|
||||
|
||||
# Sample data for generating random content
|
||||
FIRST_NAMES = [
|
||||
"James", "Emma", "Liam", "Olivia", "Noah", "Ava", "Oliver", "Sophia",
|
||||
"Elijah", "Isabella", "Lucas", "Mia", "Mason", "Charlotte", "Ethan",
|
||||
"Amelia", "Alexander", "Harper", "Henry", "Evelyn", "Sebastian", "Luna",
|
||||
"Jack", "Camila", "Aiden", "Gianna", "Owen", "Abigail", "Samuel", "Ella",
|
||||
]
|
||||
|
||||
LAST_NAMES = [
|
||||
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller",
|
||||
"Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez",
|
||||
"Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin",
|
||||
]
|
||||
|
||||
NFL_TEAMS = [
|
||||
("Kansas City Chiefs", "Arrowhead Stadium"),
|
||||
("San Francisco 49ers", "Levi's Stadium"),
|
||||
("Philadelphia Eagles", "Lincoln Financial Field"),
|
||||
("Dallas Cowboys", "AT&T Stadium"),
|
||||
("Buffalo Bills", "Highmark Stadium"),
|
||||
("Miami Dolphins", "Hard Rock Stadium"),
|
||||
("Detroit Lions", "Ford Field"),
|
||||
("Green Bay Packers", "Lambeau Field"),
|
||||
("Baltimore Ravens", "M&T Bank Stadium"),
|
||||
("Cincinnati Bengals", "Paycor Stadium"),
|
||||
]
|
||||
|
||||
NBA_TEAMS = [
|
||||
("Boston Celtics", "TD Garden"),
|
||||
("Denver Nuggets", "Ball Arena"),
|
||||
("Milwaukee Bucks", "Fiserv Forum"),
|
||||
("Los Angeles Lakers", "Crypto.com Arena"),
|
||||
("Phoenix Suns", "Footprint Center"),
|
||||
("Golden State Warriors", "Chase Center"),
|
||||
("Miami Heat", "Kaseya Center"),
|
||||
("Cleveland Cavaliers", "Rocket Mortgage FieldHouse"),
|
||||
]
|
||||
|
||||
EVENT_COMMENTS = [
|
||||
"This is going to be a great game!",
|
||||
"Home team looking strong this season",
|
||||
"I'm betting on the underdog here",
|
||||
"What do you all think about the spread?",
|
||||
"Last time these teams played it was close",
|
||||
"The odds seem off to me",
|
||||
"Sharp money coming in on the home side",
|
||||
"Value play on the underdog here",
|
||||
"Home field advantage is huge here",
|
||||
"Rivalry game, throw out the records!",
|
||||
]
|
||||
|
||||
|
||||
class SeederService:
|
||||
"""Service for seeding the database with test data."""
|
||||
|
||||
@staticmethod
|
||||
async def seed(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
request: SeedRequest,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> SeedResponse:
|
||||
"""
|
||||
Seed the database with test data.
|
||||
"""
|
||||
created_counts = {
|
||||
"users": 0,
|
||||
"wallets": 0,
|
||||
"events": 0,
|
||||
"bets": 0,
|
||||
"comments": 0,
|
||||
}
|
||||
test_admin_info = None
|
||||
|
||||
# Create test admin if requested and doesn't exist
|
||||
if request.create_admin:
|
||||
existing_admin = await db.execute(
|
||||
select(User).where(User.username == "testadmin")
|
||||
)
|
||||
if not existing_admin.scalar_one_or_none():
|
||||
test_admin = User(
|
||||
email="testadmin@example.com",
|
||||
username="testadmin",
|
||||
password_hash=get_password_hash("admin123"),
|
||||
display_name="Test Administrator",
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(test_admin)
|
||||
await db.flush()
|
||||
|
||||
admin_wallet = Wallet(
|
||||
user_id=test_admin.id,
|
||||
balance=Decimal("10000.00"),
|
||||
escrow=Decimal("0.00"),
|
||||
)
|
||||
db.add(admin_wallet)
|
||||
created_counts["users"] += 1
|
||||
created_counts["wallets"] += 1
|
||||
test_admin_info = {
|
||||
"username": "testadmin",
|
||||
"email": "testadmin@example.com",
|
||||
"password": "admin123",
|
||||
}
|
||||
|
||||
# Create regular test users
|
||||
users = []
|
||||
for i in range(request.num_users):
|
||||
first = random.choice(FIRST_NAMES)
|
||||
last = random.choice(LAST_NAMES)
|
||||
suffix = random.randint(100, 9999)
|
||||
username = f"{first.lower()}{last.lower()}{suffix}"
|
||||
email = f"{username}@example.com"
|
||||
|
||||
# Check if user already exists
|
||||
existing = await db.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
password_hash=get_password_hash("password123"),
|
||||
display_name=f"{first} {last}",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
wallet = Wallet(
|
||||
user_id=user.id,
|
||||
balance=request.starting_balance,
|
||||
escrow=Decimal("0.00"),
|
||||
)
|
||||
db.add(wallet)
|
||||
|
||||
users.append(user)
|
||||
created_counts["users"] += 1
|
||||
created_counts["wallets"] += 1
|
||||
|
||||
# Create sport events
|
||||
events = []
|
||||
all_teams = [(t, v, SportType.FOOTBALL, "NFL") for t, v in NFL_TEAMS] + \
|
||||
[(t, v, SportType.BASKETBALL, "NBA") for t, v in NBA_TEAMS]
|
||||
|
||||
for i in range(request.num_events):
|
||||
# Pick two different teams from the same sport
|
||||
team_pool = random.choice([NFL_TEAMS, NBA_TEAMS])
|
||||
sport = SportType.FOOTBALL if team_pool == NFL_TEAMS else SportType.BASKETBALL
|
||||
league = "NFL" if sport == SportType.FOOTBALL else "NBA"
|
||||
|
||||
home_team, venue = random.choice(team_pool)
|
||||
away_team, _ = random.choice([t for t in team_pool if t[0] != home_team])
|
||||
|
||||
# Random game time in the next 7 days
|
||||
game_time = datetime.utcnow() + timedelta(
|
||||
days=random.randint(1, 7),
|
||||
hours=random.randint(0, 23),
|
||||
)
|
||||
|
||||
# Random spread
|
||||
spread = round(random.uniform(-10, 10) * 2) / 2 # Half-point spread
|
||||
|
||||
event = SportEvent(
|
||||
sport=sport,
|
||||
home_team=home_team,
|
||||
away_team=away_team,
|
||||
official_spread=spread,
|
||||
game_time=game_time,
|
||||
venue=venue,
|
||||
league=league,
|
||||
min_spread=-15.0,
|
||||
max_spread=15.0,
|
||||
min_bet_amount=Decimal("10.00"),
|
||||
max_bet_amount=Decimal("1000.00"),
|
||||
status=EventStatus.UPCOMING,
|
||||
created_by=admin.id,
|
||||
)
|
||||
db.add(event)
|
||||
await db.flush()
|
||||
events.append(event)
|
||||
created_counts["events"] += 1
|
||||
|
||||
# Create bets on events
|
||||
if users and events:
|
||||
for event in events:
|
||||
num_bets = random.randint(1, request.num_bets_per_event * 2)
|
||||
for _ in range(num_bets):
|
||||
user = random.choice(users)
|
||||
|
||||
# Random spread near official
|
||||
spread_offset = random.choice([-2, -1.5, -1, -0.5, 0, 0.5, 1, 1.5, 2])
|
||||
spread = event.official_spread + spread_offset
|
||||
spread = max(event.min_spread, min(event.max_spread, spread))
|
||||
# Ensure half-point
|
||||
spread = round(spread * 2) / 2
|
||||
if spread % 1 == 0:
|
||||
spread += 0.5
|
||||
|
||||
stake = Decimal(str(random.randint(25, 200)))
|
||||
team = random.choice([TeamSide.HOME, TeamSide.AWAY])
|
||||
|
||||
bet = SpreadBet(
|
||||
event_id=event.id,
|
||||
spread=spread,
|
||||
team=team,
|
||||
creator_id=user.id,
|
||||
stake_amount=stake,
|
||||
house_commission_percent=Decimal("10.00"),
|
||||
status=SpreadBetStatus.OPEN,
|
||||
)
|
||||
db.add(bet)
|
||||
created_counts["bets"] += 1
|
||||
|
||||
# Add some comments
|
||||
for _ in range(random.randint(0, 3)):
|
||||
user = random.choice(users)
|
||||
comment = EventComment(
|
||||
event_id=event.id,
|
||||
user_id=user.id,
|
||||
content=random.choice(EVENT_COMMENTS),
|
||||
)
|
||||
db.add(comment)
|
||||
created_counts["comments"] += 1
|
||||
|
||||
# Log the seed operation
|
||||
await log_data_seed(
|
||||
db=db,
|
||||
admin=admin,
|
||||
created_counts=created_counts,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return SeedResponse(
|
||||
success=True,
|
||||
message="Database seeded successfully",
|
||||
created_counts=created_counts,
|
||||
test_admin=test_admin_info,
|
||||
)
|
||||
382
backend/app/services/simulation_service.py
Normal file
382
backend/app/services/simulation_service.py
Normal file
@ -0,0 +1,382 @@
|
||||
"""
|
||||
Simulation Service for running automated activity in the background.
|
||||
This service manages starting/stopping simulated user activity.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import async_session
|
||||
from app.models import (
|
||||
User, Wallet, SportEvent, SpreadBet, EventComment, MatchComment,
|
||||
EventStatus, SpreadBetStatus, TeamSide, Transaction, TransactionType, TransactionStatus
|
||||
)
|
||||
from app.schemas.admin import SimulationConfig, SimulationStatusResponse
|
||||
from app.utils.security import get_password_hash
|
||||
|
||||
|
||||
# Sample data for simulation
|
||||
FIRST_NAMES = [
|
||||
"James", "Emma", "Liam", "Olivia", "Noah", "Ava", "Oliver", "Sophia",
|
||||
"Elijah", "Isabella", "Lucas", "Mia", "Mason", "Charlotte", "Ethan",
|
||||
]
|
||||
|
||||
LAST_NAMES = [
|
||||
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller",
|
||||
"Davis", "Rodriguez", "Martinez",
|
||||
]
|
||||
|
||||
EVENT_COMMENTS = [
|
||||
"This is going to be a great game!",
|
||||
"Home team looking strong this season",
|
||||
"I'm betting on the underdog here",
|
||||
"What do you all think about the spread?",
|
||||
"The odds seem off to me",
|
||||
"Sharp money coming in on the home side",
|
||||
"Home field advantage is huge here",
|
||||
]
|
||||
|
||||
MATCH_COMMENTS = [
|
||||
"Good luck!",
|
||||
"May the best bettor win",
|
||||
"I'm feeling confident about this one",
|
||||
"Nice bet, looking forward to the game",
|
||||
"GL HF",
|
||||
]
|
||||
|
||||
|
||||
class SimulationManager:
|
||||
"""
|
||||
Singleton manager for controlling simulation state.
|
||||
Runs simulation in a background asyncio task.
|
||||
"""
|
||||
_instance: Optional["SimulationManager"] = None
|
||||
_task: Optional[asyncio.Task] = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
self._running = False
|
||||
self._started_at: Optional[datetime] = None
|
||||
self._started_by: Optional[str] = None
|
||||
self._iterations = 0
|
||||
self._config: Optional[SimulationConfig] = None
|
||||
self._last_activity: Optional[str] = None
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
def get_status(self) -> SimulationStatusResponse:
|
||||
return SimulationStatusResponse(
|
||||
is_running=self._running,
|
||||
started_at=self._started_at,
|
||||
started_by=self._started_by,
|
||||
iterations_completed=self._iterations,
|
||||
config=self._config,
|
||||
last_activity=self._last_activity,
|
||||
)
|
||||
|
||||
async def start(self, admin_username: str, config: Optional[SimulationConfig] = None) -> bool:
|
||||
"""Start the simulation in a background task."""
|
||||
if self._running:
|
||||
return False
|
||||
|
||||
self._config = config or SimulationConfig()
|
||||
self._running = True
|
||||
self._started_at = datetime.utcnow()
|
||||
self._started_by = admin_username
|
||||
self._iterations = 0
|
||||
self._stop_event.clear()
|
||||
|
||||
# Start background task
|
||||
SimulationManager._task = asyncio.create_task(self._run_simulation())
|
||||
return True
|
||||
|
||||
async def stop(self) -> tuple[int, float]:
|
||||
"""Stop the simulation and return stats."""
|
||||
if not self._running:
|
||||
return 0, 0.0
|
||||
|
||||
self._stop_event.set()
|
||||
if SimulationManager._task:
|
||||
try:
|
||||
await asyncio.wait_for(SimulationManager._task, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
SimulationManager._task.cancel()
|
||||
|
||||
iterations = self._iterations
|
||||
duration = (datetime.utcnow() - self._started_at).total_seconds() if self._started_at else 0
|
||||
|
||||
self._running = False
|
||||
self._started_at = None
|
||||
self._started_by = None
|
||||
self._iterations = 0
|
||||
self._config = None
|
||||
SimulationManager._task = None
|
||||
|
||||
return iterations, duration
|
||||
|
||||
async def _run_simulation(self):
|
||||
"""Main simulation loop."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
async with async_session() as db:
|
||||
# Get existing users and events
|
||||
users_result = await db.execute(select(User).where(User.is_admin == False))
|
||||
users = list(users_result.scalars().all())
|
||||
|
||||
events_result = await db.execute(
|
||||
select(SportEvent).where(SportEvent.status == EventStatus.UPCOMING)
|
||||
)
|
||||
events = list(events_result.scalars().all())
|
||||
|
||||
if not events:
|
||||
self._last_activity = "No upcoming events - waiting..."
|
||||
await asyncio.sleep(self._config.delay_seconds)
|
||||
continue
|
||||
|
||||
# Perform random actions based on config
|
||||
for _ in range(self._config.actions_per_iteration):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
action = self._pick_action()
|
||||
try:
|
||||
if action == "create_user" and self._config.create_users:
|
||||
await self._create_user(db)
|
||||
# Refresh users list
|
||||
users_result = await db.execute(select(User).where(User.is_admin == False))
|
||||
users = list(users_result.scalars().all())
|
||||
elif action == "create_bet" and self._config.create_bets and users and events:
|
||||
await self._create_bet(db, users, events)
|
||||
elif action == "take_bet" and self._config.take_bets and users:
|
||||
await self._take_bet(db, users)
|
||||
elif action == "add_comment" and self._config.add_comments and users and events:
|
||||
await self._add_comment(db, users, events)
|
||||
elif action == "cancel_bet" and self._config.cancel_bets:
|
||||
await self._cancel_bet(db)
|
||||
except Exception as e:
|
||||
self._last_activity = f"Error: {str(e)[:50]}"
|
||||
|
||||
self._iterations += 1
|
||||
await asyncio.sleep(self._config.delay_seconds)
|
||||
|
||||
except Exception as e:
|
||||
self._last_activity = f"Loop error: {str(e)[:50]}"
|
||||
await asyncio.sleep(self._config.delay_seconds)
|
||||
|
||||
def _pick_action(self) -> str:
|
||||
"""Pick a random action based on weights."""
|
||||
actions = [
|
||||
("create_user", 0.15),
|
||||
("create_bet", 0.35),
|
||||
("take_bet", 0.25),
|
||||
("add_comment", 0.20),
|
||||
("cancel_bet", 0.05),
|
||||
]
|
||||
rand = random.random()
|
||||
cumulative = 0
|
||||
for action, weight in actions:
|
||||
cumulative += weight
|
||||
if rand <= cumulative:
|
||||
return action
|
||||
return "create_bet"
|
||||
|
||||
async def _create_user(self, db):
|
||||
"""Create a random user."""
|
||||
first = random.choice(FIRST_NAMES)
|
||||
last = random.choice(LAST_NAMES)
|
||||
suffix = random.randint(100, 9999)
|
||||
username = f"{first.lower()}{last.lower()}{suffix}"
|
||||
email = f"{username}@example.com"
|
||||
|
||||
# Check if exists
|
||||
existing = await db.execute(select(User).where(User.username == username))
|
||||
if existing.scalar_one_or_none():
|
||||
return
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
password_hash=get_password_hash("password123"),
|
||||
display_name=f"{first} {last}",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
wallet = Wallet(
|
||||
user_id=user.id,
|
||||
balance=Decimal(str(random.randint(500, 5000))),
|
||||
escrow=Decimal("0.00"),
|
||||
)
|
||||
db.add(wallet)
|
||||
await db.commit()
|
||||
|
||||
self._last_activity = f"Created user: {username}"
|
||||
|
||||
async def _create_bet(self, db, users: list[User], events: list[SportEvent]):
|
||||
"""Create a random bet."""
|
||||
# Find users with balance
|
||||
users_with_balance = []
|
||||
for user in users:
|
||||
wallet_result = await db.execute(select(Wallet).where(Wallet.user_id == user.id))
|
||||
wallet = wallet_result.scalar_one_or_none()
|
||||
if wallet and wallet.balance >= Decimal("10"):
|
||||
users_with_balance.append((user, wallet))
|
||||
|
||||
if not users_with_balance:
|
||||
return
|
||||
|
||||
user, wallet = random.choice(users_with_balance)
|
||||
event = random.choice(events)
|
||||
|
||||
spread = round(random.uniform(event.min_spread, event.max_spread) * 2) / 2
|
||||
if spread % 1 == 0:
|
||||
spread += 0.5
|
||||
|
||||
max_stake = min(float(wallet.balance) * 0.5, 500)
|
||||
stake = Decimal(str(round(random.uniform(10, max(10, max_stake)), 2)))
|
||||
team = random.choice([TeamSide.HOME, TeamSide.AWAY])
|
||||
|
||||
bet = SpreadBet(
|
||||
event_id=event.id,
|
||||
spread=spread,
|
||||
team=team,
|
||||
creator_id=user.id,
|
||||
stake_amount=stake,
|
||||
house_commission_percent=Decimal("10.00"),
|
||||
status=SpreadBetStatus.OPEN,
|
||||
)
|
||||
db.add(bet)
|
||||
await db.commit()
|
||||
|
||||
team_name = event.home_team if team == TeamSide.HOME else event.away_team
|
||||
self._last_activity = f"{user.username} created ${stake} bet on {team_name}"
|
||||
|
||||
async def _take_bet(self, db, users: list[User]):
|
||||
"""Take a random open bet."""
|
||||
result = await db.execute(
|
||||
select(SpreadBet)
|
||||
.options(selectinload(SpreadBet.event), selectinload(SpreadBet.creator))
|
||||
.where(SpreadBet.status == SpreadBetStatus.OPEN)
|
||||
)
|
||||
open_bets = result.scalars().all()
|
||||
|
||||
if not open_bets:
|
||||
return
|
||||
|
||||
bet = random.choice(open_bets)
|
||||
|
||||
# Find eligible takers
|
||||
eligible = []
|
||||
for user in users:
|
||||
if user.id == bet.creator_id:
|
||||
continue
|
||||
wallet_result = await db.execute(select(Wallet).where(Wallet.user_id == user.id))
|
||||
wallet = wallet_result.scalar_one_or_none()
|
||||
if wallet and wallet.balance >= bet.stake_amount:
|
||||
eligible.append((user, wallet))
|
||||
|
||||
if not eligible:
|
||||
return
|
||||
|
||||
taker, taker_wallet = random.choice(eligible)
|
||||
|
||||
# Get creator wallet
|
||||
creator_wallet_result = await db.execute(
|
||||
select(Wallet).where(Wallet.user_id == bet.creator_id)
|
||||
)
|
||||
creator_wallet = creator_wallet_result.scalar_one_or_none()
|
||||
|
||||
if not creator_wallet or creator_wallet.balance < bet.stake_amount:
|
||||
return
|
||||
|
||||
# Lock funds
|
||||
creator_wallet.balance -= bet.stake_amount
|
||||
creator_wallet.escrow += bet.stake_amount
|
||||
taker_wallet.balance -= bet.stake_amount
|
||||
taker_wallet.escrow += bet.stake_amount
|
||||
|
||||
# Create transactions
|
||||
creator_tx = Transaction(
|
||||
user_id=bet.creator_id,
|
||||
wallet_id=creator_wallet.id,
|
||||
type=TransactionType.ESCROW_LOCK,
|
||||
amount=-bet.stake_amount,
|
||||
balance_after=creator_wallet.balance,
|
||||
reference_id=bet.id,
|
||||
description=f"Escrow lock for spread bet #{bet.id}",
|
||||
status=TransactionStatus.COMPLETED,
|
||||
)
|
||||
taker_tx = Transaction(
|
||||
user_id=taker.id,
|
||||
wallet_id=taker_wallet.id,
|
||||
type=TransactionType.ESCROW_LOCK,
|
||||
amount=-bet.stake_amount,
|
||||
balance_after=taker_wallet.balance,
|
||||
reference_id=bet.id,
|
||||
description=f"Escrow lock for spread bet #{bet.id}",
|
||||
status=TransactionStatus.COMPLETED,
|
||||
)
|
||||
db.add(creator_tx)
|
||||
db.add(taker_tx)
|
||||
|
||||
bet.taker_id = taker.id
|
||||
bet.status = SpreadBetStatus.MATCHED
|
||||
bet.matched_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
self._last_activity = f"{taker.username} took ${bet.stake_amount} bet"
|
||||
|
||||
async def _add_comment(self, db, users: list[User], events: list[SportEvent]):
|
||||
"""Add a random comment."""
|
||||
user = random.choice(users)
|
||||
event = random.choice(events)
|
||||
content = random.choice(EVENT_COMMENTS)
|
||||
|
||||
comment = EventComment(
|
||||
event_id=event.id,
|
||||
user_id=user.id,
|
||||
content=content,
|
||||
)
|
||||
db.add(comment)
|
||||
await db.commit()
|
||||
self._last_activity = f"{user.username} commented on {event.home_team} vs {event.away_team}"
|
||||
|
||||
async def _cancel_bet(self, db):
|
||||
"""Cancel a random open bet."""
|
||||
if random.random() > 0.2: # Only 20% chance
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(SpreadBet)
|
||||
.options(selectinload(SpreadBet.creator))
|
||||
.where(SpreadBet.status == SpreadBetStatus.OPEN)
|
||||
)
|
||||
open_bets = result.scalars().all()
|
||||
|
||||
if not open_bets:
|
||||
return
|
||||
|
||||
bet = random.choice(open_bets)
|
||||
bet.status = SpreadBetStatus.CANCELLED
|
||||
await db.commit()
|
||||
self._last_activity = f"{bet.creator.username} cancelled ${bet.stake_amount} bet"
|
||||
|
||||
|
||||
# Global instance
|
||||
simulation_manager = SimulationManager()
|
||||
224
backend/app/services/wiper_service.py
Normal file
224
backend/app/services/wiper_service.py
Normal file
@ -0,0 +1,224 @@
|
||||
"""
|
||||
Data Wiper Service for safely clearing database data.
|
||||
Includes safeguards like confirmation phrases and cooldowns.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete, func
|
||||
|
||||
from app.models import (
|
||||
User, Wallet, Transaction, Bet, SpreadBet, SportEvent,
|
||||
EventComment, MatchComment, AdminSettings, AdminAuditLog,
|
||||
UserStats, Achievement, UserAchievement, LootBox, ActivityFeed, DailyReward
|
||||
)
|
||||
from app.schemas.admin import WipePreviewResponse, WipeRequest, WipeResponse
|
||||
from app.services.audit_service import log_data_wipe
|
||||
|
||||
|
||||
# Cooldown between wipes (5 minutes)
|
||||
WIPE_COOLDOWN_SECONDS = 300
|
||||
CONFIRMATION_PHRASE = "CONFIRM WIPE"
|
||||
|
||||
|
||||
class WiperService:
|
||||
"""Service for wiping database data with safeguards."""
|
||||
|
||||
@staticmethod
|
||||
async def get_preview(db: AsyncSession) -> WipePreviewResponse:
|
||||
"""
|
||||
Get a preview of what would be deleted in a wipe operation.
|
||||
Also checks cooldown status.
|
||||
"""
|
||||
# Count all entities
|
||||
users_count = (await db.execute(select(func.count(User.id)))).scalar() or 0
|
||||
admin_users = (await db.execute(
|
||||
select(func.count(User.id)).where(User.is_admin == True)
|
||||
)).scalar() or 0
|
||||
wallets_count = (await db.execute(select(func.count(Wallet.id)))).scalar() or 0
|
||||
transactions_count = (await db.execute(select(func.count(Transaction.id)))).scalar() or 0
|
||||
bets_count = (await db.execute(select(func.count(Bet.id)))).scalar() or 0
|
||||
spread_bets_count = (await db.execute(select(func.count(SpreadBet.id)))).scalar() or 0
|
||||
events_count = (await db.execute(select(func.count(SportEvent.id)))).scalar() or 0
|
||||
event_comments_count = (await db.execute(select(func.count(EventComment.id)))).scalar() or 0
|
||||
match_comments_count = (await db.execute(select(func.count(MatchComment.id)))).scalar() or 0
|
||||
|
||||
# Check last wipe time from audit log
|
||||
last_wipe_log = await db.execute(
|
||||
select(AdminAuditLog)
|
||||
.where(AdminAuditLog.action == "DATA_WIPE")
|
||||
.order_by(AdminAuditLog.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
last_wipe = last_wipe_log.scalar_one_or_none()
|
||||
|
||||
cooldown_remaining = 0
|
||||
can_wipe = True
|
||||
last_wipe_at = None
|
||||
|
||||
if last_wipe:
|
||||
last_wipe_at = last_wipe.created_at
|
||||
elapsed = (datetime.utcnow() - last_wipe.created_at).total_seconds()
|
||||
if elapsed < WIPE_COOLDOWN_SECONDS:
|
||||
cooldown_remaining = int(WIPE_COOLDOWN_SECONDS - elapsed)
|
||||
can_wipe = False
|
||||
|
||||
return WipePreviewResponse(
|
||||
users_count=users_count - admin_users, # Non-admin users that would be deleted
|
||||
wallets_count=wallets_count,
|
||||
transactions_count=transactions_count,
|
||||
bets_count=bets_count,
|
||||
spread_bets_count=spread_bets_count,
|
||||
events_count=events_count,
|
||||
event_comments_count=event_comments_count,
|
||||
match_comments_count=match_comments_count,
|
||||
admin_settings_preserved=True,
|
||||
admin_users_preserved=True,
|
||||
can_wipe=can_wipe,
|
||||
cooldown_remaining_seconds=cooldown_remaining,
|
||||
last_wipe_at=last_wipe_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def execute_wipe(
|
||||
db: AsyncSession,
|
||||
admin: User,
|
||||
request: WipeRequest,
|
||||
ip_address: Optional[str] = None,
|
||||
) -> WipeResponse:
|
||||
"""
|
||||
Execute a database wipe with safeguards.
|
||||
|
||||
Raises:
|
||||
ValueError: If confirmation phrase is wrong or cooldown not elapsed
|
||||
"""
|
||||
# Verify confirmation phrase
|
||||
if request.confirmation_phrase != CONFIRMATION_PHRASE:
|
||||
raise ValueError(f"Invalid confirmation phrase. Must be exactly '{CONFIRMATION_PHRASE}'")
|
||||
|
||||
# Check cooldown
|
||||
preview = await WiperService.get_preview(db)
|
||||
if not preview.can_wipe:
|
||||
raise ValueError(
|
||||
f"Wipe cooldown in effect. Please wait {preview.cooldown_remaining_seconds} seconds."
|
||||
)
|
||||
|
||||
deleted_counts = {}
|
||||
preserved_counts = {}
|
||||
|
||||
# Get admin user IDs to preserve
|
||||
admin_user_ids = []
|
||||
if request.preserve_admin_users:
|
||||
admin_users_result = await db.execute(
|
||||
select(User.id).where(User.is_admin == True)
|
||||
)
|
||||
admin_user_ids = [row[0] for row in admin_users_result.fetchall()]
|
||||
preserved_counts["admin_users"] = len(admin_user_ids)
|
||||
|
||||
# Delete in order respecting foreign keys:
|
||||
# 1. Comments (no FK dependencies)
|
||||
result = await db.execute(delete(MatchComment))
|
||||
deleted_counts["match_comments"] = result.rowcount
|
||||
|
||||
result = await db.execute(delete(EventComment))
|
||||
deleted_counts["event_comments"] = result.rowcount
|
||||
|
||||
# 2. Gamification data
|
||||
result = await db.execute(delete(ActivityFeed))
|
||||
deleted_counts["activity_feed"] = result.rowcount
|
||||
|
||||
result = await db.execute(delete(DailyReward))
|
||||
deleted_counts["daily_rewards"] = result.rowcount
|
||||
|
||||
result = await db.execute(delete(LootBox))
|
||||
deleted_counts["loot_boxes"] = result.rowcount
|
||||
|
||||
result = await db.execute(delete(UserAchievement))
|
||||
deleted_counts["user_achievements"] = result.rowcount
|
||||
|
||||
result = await db.execute(delete(UserStats))
|
||||
deleted_counts["user_stats"] = result.rowcount
|
||||
|
||||
# 3. Bets
|
||||
result = await db.execute(delete(SpreadBet))
|
||||
deleted_counts["spread_bets"] = result.rowcount
|
||||
|
||||
result = await db.execute(delete(Bet))
|
||||
deleted_counts["bets"] = result.rowcount
|
||||
|
||||
# 4. Events (only if not preserving)
|
||||
if not request.preserve_events:
|
||||
result = await db.execute(delete(SportEvent))
|
||||
deleted_counts["events"] = result.rowcount
|
||||
else:
|
||||
events_count = (await db.execute(select(func.count(SportEvent.id)))).scalar() or 0
|
||||
preserved_counts["events"] = events_count
|
||||
|
||||
# 5. Transactions
|
||||
if admin_user_ids:
|
||||
result = await db.execute(
|
||||
delete(Transaction).where(Transaction.user_id.notin_(admin_user_ids))
|
||||
)
|
||||
else:
|
||||
result = await db.execute(delete(Transaction))
|
||||
deleted_counts["transactions"] = result.rowcount
|
||||
|
||||
# 6. Wallets (preserve admin wallets, but reset them)
|
||||
if admin_user_ids:
|
||||
# Delete non-admin wallets
|
||||
result = await db.execute(
|
||||
delete(Wallet).where(Wallet.user_id.notin_(admin_user_ids))
|
||||
)
|
||||
deleted_counts["wallets"] = result.rowcount
|
||||
|
||||
# Reset admin wallets
|
||||
from decimal import Decimal
|
||||
admin_wallets = await db.execute(
|
||||
select(Wallet).where(Wallet.user_id.in_(admin_user_ids))
|
||||
)
|
||||
for wallet in admin_wallets.scalars():
|
||||
wallet.balance = Decimal("1000.00")
|
||||
wallet.escrow = Decimal("0.00")
|
||||
preserved_counts["admin_wallets"] = len(admin_user_ids)
|
||||
else:
|
||||
result = await db.execute(delete(Wallet))
|
||||
deleted_counts["wallets"] = result.rowcount
|
||||
|
||||
# 7. Users (preserve admins)
|
||||
if admin_user_ids:
|
||||
result = await db.execute(
|
||||
delete(User).where(User.id.notin_(admin_user_ids))
|
||||
)
|
||||
# Reset admin user stats
|
||||
admin_users = await db.execute(
|
||||
select(User).where(User.id.in_(admin_user_ids))
|
||||
)
|
||||
for user in admin_users.scalars():
|
||||
user.total_bets = 0
|
||||
user.wins = 0
|
||||
user.losses = 0
|
||||
user.win_rate = 0.0
|
||||
else:
|
||||
result = await db.execute(delete(User))
|
||||
deleted_counts["users"] = result.rowcount
|
||||
|
||||
# Log the wipe before committing
|
||||
await log_data_wipe(
|
||||
db=db,
|
||||
admin=admin,
|
||||
deleted_counts=deleted_counts,
|
||||
preserved_counts=preserved_counts,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return WipeResponse(
|
||||
success=True,
|
||||
message="Database wiped successfully",
|
||||
deleted_counts=deleted_counts,
|
||||
preserved_counts=preserved_counts,
|
||||
executed_at=datetime.utcnow(),
|
||||
executed_by=admin.username,
|
||||
)
|
||||
Reference in New Issue
Block a user