Files
dodep-simulator/database.py
T

372 lines
16 KiB
Python

import os
import json
from pathlib import Path
from datetime import datetime
from contextlib import asynccontextmanager
from typing import AsyncGenerator, Generator, Optional
from sqlalchemy import (
create_engine, Column, Integer, String, Float, Boolean,
DateTime, ForeignKey, Text, text, inspect, Index
)
from sqlalchemy.orm import sessionmaker, relationship, DeclarativeBase
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
# ─── Config ──────────────────────────────────────────────────────────────────
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./cs2_simulator.db")
ASYNC_DATABASE_URL = os.getenv("ASYNC_DATABASE_URL", "sqlite+aiosqlite:///./cs2_simulator.db")
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
USE_ASYNC = os.getenv("USE_ASYNC", "0") == "1" or "postgresql" in ASYNC_DATABASE_URL
class Base(DeclarativeBase):
pass
# ─── Sync engine (SQLite fallback / admin) ──────────────────────────────────
is_sqlite = DATABASE_URL.startswith("sqlite")
sync_connect_args = {"check_same_thread": False} if is_sqlite else {}
engine = create_engine(DATABASE_URL, connect_args=sync_connect_args, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
if is_sqlite:
with engine.connect() as conn:
conn.execute(text("PRAGMA journal_mode=WAL"))
conn.execute(text("PRAGMA synchronous=NORMAL"))
conn.execute(text("PRAGMA cache_size=-64000"))
conn.execute(text("PRAGMA busy_timeout=5000"))
conn.commit()
# ─── Async engine (PostgreSQL production) ───────────────────────────────────
async_engine = create_async_engine(
ASYNC_DATABASE_URL,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
echo=False,
)
AsyncSessionLocal = async_sessionmaker(async_engine, expire_on_commit=False)
# ─── Models ─────────────────────────────────────────────────────────────────
class User(Base):
__tablename__ = "users"
__table_args__ = (
Index("ix_users_username", "username"),
Index("ix_users_created_at", "created_at"),
)
id = Column(Integer, primary_key=True)
username = Column(String(50), unique=True, nullable=False)
hashed_password = Column(String(200), nullable=False)
balance = Column(Float, default=0.0)
is_admin = Column(Boolean, default=False)
is_banned = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime, nullable=True)
card_balance_cached = Column(Float, default=1000.0)
total_deposited = Column(Float, default=0.0)
total_withdrawn = Column(Float, default=0.0)
last_deposit_date = Column(DateTime, nullable=True)
inventory_items = relationship("InventoryItem", back_populates="user", cascade="all, delete-orphan")
case_openings = relationship("CaseOpening", back_populates="user", cascade="all, delete-orphan")
contracts = relationship("Contract", back_populates="user", cascade="all, delete-orphan")
rpu_settings = relationship("UserRPU", back_populates="user", uselist=False, cascade="all, delete-orphan")
upgrades = relationship("Upgrade", back_populates="user", cascade="all, delete-orphan")
upgrade_rpu = relationship("UpgradeRPU", back_populates="user", uselist=False, cascade="all, delete-orphan")
transactions = relationship("TransactionLog", back_populates="user", cascade="all, delete-orphan")
class UpgradeRPU(Base):
__tablename__ = "upgrade_rpu"
__table_args__ = (Index("ix_upgrade_rpu_user_id", "user_id", unique=True),)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
upgrade_multiplier = Column(Float, default=1.0)
auto_adjust = Column(Boolean, default=False)
total_attempts = Column(Integer, default=0)
total_success = Column(Integer, default=0)
total_spent_value = Column(Float, default=0.0)
current_win_streak = Column(Integer, default=0)
current_lose_streak = Column(Integer, default=0)
best_win_streak = Column(Integer, default=0)
worst_lose_streak = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="upgrade_rpu")
class Upgrade(Base):
__tablename__ = "upgrades"
__table_args__ = (Index("ix_upgrades_user_id_created", "user_id", "created_at"),)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
input_item_id = Column(Integer, nullable=False)
input_item_name = Column(String(200))
input_item_image_url = Column(String(500), default="")
target_item_id = Column(Integer, nullable=False)
target_item_name = Column(String(200))
target_item_image_url = Column(String(500), default="")
success = Column(Boolean, default=False)
probability = Column(Float)
rpu_adjusted_probability = Column(Float)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="upgrades")
class UserRPU(Base):
__tablename__ = "user_rpu"
__table_args__ = (Index("ix_user_rpu_user_id", "user_id", unique=True),)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
consumer_multiplier = Column(Float, default=1.0)
industrial_multiplier = Column(Float, default=1.0)
mil_spec_multiplier = Column(Float, default=1.0)
restricted_multiplier = Column(Float, default=1.0)
classified_multiplier = Column(Float, default=1.0)
covert_multiplier = Column(Float, default=1.0)
rare_special_multiplier = Column(Float, default=1.0)
luck_multiplier = Column(Float, default=1.0)
auto_adjust = Column(Boolean, default=False)
total_spent = Column(Float, default=0.0)
total_opened = Column(Integer, default=0)
total_value_received = Column(Float, default=0.0)
last_adjustment = Column(DateTime, nullable=True)
current_streak = Column(Integer, default=0)
best_streak = Column(Integer, default=0)
worst_streak = Column(Integer, default=0)
last_results = Column(String(500), default="")
luck_budget = Column(Float, default=100.0)
session_spent = Column(Float, default=0.0)
session_won = Column(Float, default=0.0)
session_reset_date = Column(DateTime, nullable=True)
ceiling_multiplier = Column(Float, default=1.0)
ceiling_break_count = Column(Integer, default=0)
ceiling_break_session = Column(Integer, default=0)
consecutive_loss_value = Column(Float, default=0.0)
consecutive_loss_count = Column(Integer, default=0)
comeback_active = Column(Boolean, default=False)
comeback_openings_left = Column(Integer, default=0)
comeback_multiplier = Column(Float, default=1.0)
hot_score = Column(Float, default=0.0)
last_activity_date = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="rpu_settings")
class PromoCode(Base):
__tablename__ = "promo_codes"
__table_args__ = (Index("ix_promo_codes_code", "code"), Index("ix_promo_codes_active", "is_active"))
id = Column(Integer, primary_key=True)
code = Column(String(50), unique=True, nullable=False)
reward_type = Column(String(30), nullable=False)
reward_amount = Column(Float, default=0.0)
reward_data = Column(String(500), default="")
max_uses = Column(Integer, default=1)
used_count = Column(Integer, default=0)
start_date = Column(DateTime, default=datetime.utcnow)
end_date = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
created_by = Column(Integer, nullable=True)
class TransactionLog(Base):
__tablename__ = "transaction_log"
__table_args__ = (
Index("ix_txlog_user_id", "user_id"),
Index("ix_txlog_created", "created_at"),
Index("ix_txlog_user_created", "user_id", "created_at"),
)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
tx_type = Column(String(30), nullable=False)
amount = Column(Float, default=0.0)
fee = Column(Float, default=0.0)
promo_code = Column(String(50), nullable=True)
item_name = Column(String(200), nullable=True)
inventory_item_id = Column(Integer, nullable=True)
details = Column(String(500), default="")
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="transactions")
class InventoryItem(Base):
__tablename__ = "inventory_items"
__table_args__ = (
Index("ix_inventory_user_id", "user_id"),
Index("ix_inventory_user_obtained", "user_id", "obtained_at"),
)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
item_id = Column(Integer, nullable=False)
market_hash_name = Column(String(200), nullable=False)
rarity = Column(String(50))
wear = Column(String(50))
float_value = Column(Float, default=0.0)
type = Column(String(20), default="Normal")
obtained_from = Column(String(100))
obtained_at = Column(DateTime, default=datetime.utcnow)
is_equipped = Column(Boolean, default=False)
image_url = Column(String(500), default="")
price_rub = Column(Float, default=0.0)
user = relationship("User", back_populates="inventory_items")
class CaseOpening(Base):
__tablename__ = "case_openings"
__table_args__ = (
Index("ix_case_openings_user_id", "user_id"),
Index("ix_case_openings_opened_at", "opened_at"),
Index("ix_case_openings_user_opened", "user_id", "opened_at"),
)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
case_name = Column(String(100), nullable=False)
item_id = Column(Integer, nullable=False)
item_name = Column(String(200), nullable=False)
rarity = Column(String(50))
float_value = Column(Float)
opened_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="case_openings")
class Contract(Base):
__tablename__ = "contracts"
__table_args__ = (
Index("ix_contracts_user_id", "user_id"),
Index("ix_contracts_user_created", "user_id", "created_at"),
)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
input_item_ids = Column(Text, nullable=False)
output_item_id = Column(Integer, nullable=False)
output_item_name = Column(String(200))
output_float = Column(Float)
probability = Column(Float)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="contracts")
class Achievement(Base):
__tablename__ = "achievements"
id = Column(Integer, primary_key=True)
name = Column(String(100), unique=True, nullable=False)
title = Column(String(200), nullable=False)
description = Column(String(500))
icon = Column(String(50), default="🏆")
category = Column(String(50), default="general")
requirement_type = Column(String(50), nullable=False)
requirement_value = Column(Integer, nullable=False)
reward_amount = Column(Float, default=0.0)
sort_order = Column(Integer, default=0)
hidden = Column(Boolean, default=False)
user_achievements = relationship("UserAchievement", back_populates="achievement")
class UserAchievement(Base):
__tablename__ = "user_achievements"
__table_args__ = (
Index("ix_user_achievements_user", "user_id"),
Index("ix_user_achievements_ach", "achievement_id"),
Index("ix_user_achievements_both", "user_id", "achievement_id", unique=True),
)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
achievement_id = Column(Integer, ForeignKey("achievements.id"), nullable=False)
progress = Column(Integer, default=0)
unlocked_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", backref="user_achievements")
achievement = relationship("Achievement", back_populates="user_achievements")
class ActivityFeed(Base):
__tablename__ = "activity_feed"
__table_args__ = (
Index("ix_activity_user_id", "user_id"),
Index("ix_activity_created", "created_at"),
)
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
username = Column(String(50), nullable=False)
activity_type = Column(String(50), nullable=False)
message = Column(String(500), nullable=False)
data_json = Column(Text, default="{}")
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", backref="activities")
# ─── Create tables ──────────────────────────────────────────────────────────
def init_sync_db():
Base.metadata.create_all(bind=engine)
_run_migrations(engine, is_sync=True)
async def init_async_db():
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
def _run_migrations(engine_or_conn, is_sync: bool):
try:
from sqlalchemy import inspect as sa_inspect
inspector = sa_inspect(engine_or_conn) if is_sync else sa_inspect(engine_or_conn)
existing_tables = inspector.get_table_names()
for tbl_name in Base.metadata.tables:
if tbl_name not in existing_tables:
continue
model_cols = {c.name for c in Base.metadata.tables[tbl_name].columns}
db_cols = {c["name"] for c in inspector.get_columns(tbl_name)}
missing = model_cols - db_cols
if missing:
print(f"[DB] Migrating {tbl_name}: adding {missing}")
except Exception as e:
print(f"[DB] Migration check: {e}")
# ─── Session helpers ────────────────────────────────────────────────────────
def get_db() -> Generator:
db = SessionLocal()
try:
yield db
finally:
db.close()
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as db:
try:
yield db
finally:
await db.close()
# ─── Thread-safe sync session wrapper for async endpoints ──────────────────
# Use this as a bridge: runs sync DB ops in executor to avoid blocking event loop.
# The session is closed when the context exits.
import asyncio
from contextlib import asynccontextmanager
@asynccontextmanager
async def get_db_executor():
"""Async context manager wrapping sync SessionLocal in executor thread.
Use for endpoints that haven't been fully async-converted yet."""
loop = asyncio.get_running_loop()
db = await loop.run_in_executor(None, lambda: SessionLocal())
try:
yield db
finally:
await loop.run_in_executor(None, db.close)