Files
remnawave-bedolaga-telegram…/app/database/crud/wheel.py
T
c0mrade 9a2aea038a chore: add uv package manager and ruff linter configuration
- Add pyproject.toml with uv and ruff configuration
- Pin Python version to 3.13 via .python-version
- Add Makefile commands: lint, format, fix
- Apply ruff formatting to entire codebase
- Remove unused imports (base64 in yookassa/simple_subscription)
- Update .gitignore for new config files
2026-01-24 17:45:27 +03:00

406 lines
13 KiB
Python

"""
CRUD операции для колеса удачи (Fortune Wheel).
"""
import logging
from datetime import datetime
from typing import Any
from sqlalchemy import and_, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
WheelConfig,
WheelPrize,
WheelSpin,
)
logger = logging.getLogger(__name__)
# ==================== WHEEL CONFIG ====================
async def get_wheel_config(db: AsyncSession) -> WheelConfig | None:
"""Получить текущую конфигурацию колеса (всегда id=1)."""
result = await db.execute(select(WheelConfig).options(selectinload(WheelConfig.prizes)).where(WheelConfig.id == 1))
return result.scalar_one_or_none()
async def get_or_create_wheel_config(db: AsyncSession) -> WheelConfig:
"""Получить или создать конфигурацию колеса."""
config = await get_wheel_config(db)
if config:
return config
# Создаем дефолтную конфигурацию
config = WheelConfig(
id=1,
is_enabled=False,
name='Колесо удачи',
spin_cost_stars=10,
spin_cost_days=1,
spin_cost_stars_enabled=True,
spin_cost_days_enabled=True,
rtp_percent=80,
daily_spin_limit=5,
min_subscription_days_for_day_payment=3,
promo_prefix='WHEEL',
promo_validity_days=7,
)
db.add(config)
await db.commit()
await db.refresh(config)
logger.info('🎡 Создана дефолтная конфигурация колеса удачи')
return config
async def update_wheel_config(db: AsyncSession, **kwargs) -> WheelConfig:
"""Обновить конфигурацию колеса."""
config = await get_or_create_wheel_config(db)
for key, value in kwargs.items():
if hasattr(config, key) and value is not None:
setattr(config, key, value)
config.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(config)
logger.info(f'🎡 Обновлена конфигурация колеса: {kwargs}')
return config
# ==================== WHEEL PRIZES ====================
async def get_wheel_prizes(db: AsyncSession, config_id: int = 1, active_only: bool = True) -> list[WheelPrize]:
"""Получить список призов колеса."""
query = select(WheelPrize).where(WheelPrize.config_id == config_id)
if active_only:
query = query.where(WheelPrize.is_active == True)
query = query.order_by(WheelPrize.sort_order)
result = await db.execute(query)
return list(result.scalars().all())
async def get_wheel_prize_by_id(db: AsyncSession, prize_id: int) -> WheelPrize | None:
"""Получить приз по ID."""
result = await db.execute(select(WheelPrize).where(WheelPrize.id == prize_id))
return result.scalar_one_or_none()
async def create_wheel_prize(
db: AsyncSession,
config_id: int,
prize_type: str,
prize_value: int,
display_name: str,
prize_value_kopeks: int,
emoji: str = '🎁',
color: str = '#3B82F6',
sort_order: int = 0,
manual_probability: float | None = None,
is_active: bool = True,
promo_balance_bonus_kopeks: int = 0,
promo_subscription_days: int = 0,
promo_traffic_gb: int = 0,
) -> WheelPrize:
"""Создать новый приз на колесе."""
prize = WheelPrize(
config_id=config_id,
prize_type=prize_type,
prize_value=prize_value,
display_name=display_name,
prize_value_kopeks=prize_value_kopeks,
emoji=emoji,
color=color,
sort_order=sort_order,
manual_probability=manual_probability,
is_active=is_active,
promo_balance_bonus_kopeks=promo_balance_bonus_kopeks,
promo_subscription_days=promo_subscription_days,
promo_traffic_gb=promo_traffic_gb,
)
db.add(prize)
await db.commit()
await db.refresh(prize)
logger.info(f'🎁 Создан приз колеса: {display_name} ({prize_type})')
return prize
async def update_wheel_prize(db: AsyncSession, prize_id: int, **kwargs) -> WheelPrize | None:
"""Обновить приз колеса."""
prize = await get_wheel_prize_by_id(db, prize_id)
if not prize:
return None
for key, value in kwargs.items():
if hasattr(prize, key) and value is not None:
setattr(prize, key, value)
prize.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(prize)
logger.info(f'🎁 Обновлен приз колеса ID={prize_id}: {kwargs}')
return prize
async def delete_wheel_prize(db: AsyncSession, prize_id: int) -> bool:
"""Удалить приз колеса."""
prize = await get_wheel_prize_by_id(db, prize_id)
if not prize:
return False
await db.delete(prize)
await db.commit()
logger.info(f'🗑️ Удален приз колеса ID={prize_id}')
return True
async def reorder_wheel_prizes(db: AsyncSession, prize_ids: list[int]) -> bool:
"""Переупорядочить призы колеса."""
for index, prize_id in enumerate(prize_ids):
prize = await get_wheel_prize_by_id(db, prize_id)
if prize:
prize.sort_order = index
await db.commit()
logger.info(f'🔄 Переупорядочены призы колеса: {prize_ids}')
return True
# ==================== WHEEL SPINS ====================
async def create_wheel_spin(
db: AsyncSession,
user_id: int,
prize_id: int,
payment_type: str,
payment_amount: int,
payment_value_kopeks: int,
prize_type: str,
prize_value: int,
prize_display_name: str,
prize_value_kopeks: int,
generated_promocode_id: int | None = None,
is_applied: bool = False,
) -> WheelSpin:
"""Создать запись о спине колеса."""
spin = WheelSpin(
user_id=user_id,
prize_id=prize_id,
payment_type=payment_type,
payment_amount=payment_amount,
payment_value_kopeks=payment_value_kopeks,
prize_type=prize_type,
prize_value=prize_value,
prize_display_name=prize_display_name,
prize_value_kopeks=prize_value_kopeks,
generated_promocode_id=generated_promocode_id,
is_applied=is_applied,
applied_at=datetime.utcnow() if is_applied else None,
)
db.add(spin)
await db.commit()
await db.refresh(spin)
logger.info(f"🎰 Создан спин колеса: user_id={user_id}, prize='{prize_display_name}'")
return spin
async def mark_spin_applied(db: AsyncSession, spin_id: int) -> WheelSpin | None:
"""Отметить спин как примененный."""
result = await db.execute(select(WheelSpin).where(WheelSpin.id == spin_id))
spin = result.scalar_one_or_none()
if spin:
spin.is_applied = True
spin.applied_at = datetime.utcnow()
await db.commit()
await db.refresh(spin)
return spin
async def get_user_spins_today(db: AsyncSession, user_id: int) -> int:
"""Получить количество спинов пользователя за сегодня."""
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
result = await db.execute(
select(func.count(WheelSpin.id)).where(
and_(
WheelSpin.user_id == user_id,
WheelSpin.created_at >= today_start,
)
)
)
return result.scalar() or 0
async def get_user_spin_history(
db: AsyncSession, user_id: int, limit: int = 20, offset: int = 0
) -> tuple[list[WheelSpin], int]:
"""Получить историю спинов пользователя."""
# Общее количество
count_result = await db.execute(select(func.count(WheelSpin.id)).where(WheelSpin.user_id == user_id))
total = count_result.scalar() or 0
# Спины с пагинацией (eager load prize relationship)
result = await db.execute(
select(WheelSpin)
.options(selectinload(WheelSpin.prize))
.where(WheelSpin.user_id == user_id)
.order_by(desc(WheelSpin.created_at))
.limit(limit)
.offset(offset)
)
spins = list(result.scalars().all())
return spins, total
async def get_all_spins(
db: AsyncSession,
user_id: int | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[WheelSpin], int]:
"""Получить все спины с фильтрами (для админки)."""
conditions = []
if user_id:
conditions.append(WheelSpin.user_id == user_id)
if date_from:
conditions.append(WheelSpin.created_at >= date_from)
if date_to:
conditions.append(WheelSpin.created_at <= date_to)
# Общее количество
count_query = select(func.count(WheelSpin.id))
if conditions:
count_query = count_query.where(and_(*conditions))
count_result = await db.execute(count_query)
total = count_result.scalar() or 0
# Спины с пагинацией
query = select(WheelSpin).options(selectinload(WheelSpin.user))
if conditions:
query = query.where(and_(*conditions))
query = query.order_by(desc(WheelSpin.created_at)).limit(limit).offset(offset)
result = await db.execute(query)
spins = list(result.scalars().all())
return spins, total
# ==================== STATISTICS ====================
async def get_wheel_statistics(
db: AsyncSession, date_from: datetime | None = None, date_to: datetime | None = None
) -> dict[str, Any]:
"""Получить статистику колеса удачи."""
conditions = []
if date_from:
conditions.append(WheelSpin.created_at >= date_from)
if date_to:
conditions.append(WheelSpin.created_at <= date_to)
base_query = select(WheelSpin)
if conditions:
base_query = base_query.where(and_(*conditions))
# Общие метрики
result = await db.execute(
select(
func.count(WheelSpin.id).label('total_spins'),
func.coalesce(func.sum(WheelSpin.payment_value_kopeks), 0).label('total_revenue'),
func.coalesce(func.sum(WheelSpin.prize_value_kopeks), 0).label('total_payout'),
).where(and_(*conditions) if conditions else True)
)
row = result.one()
total_spins = row.total_spins or 0
total_revenue = row.total_revenue or 0
total_payout = row.total_payout or 0
# Фактический RTP
actual_rtp = (total_payout / total_revenue * 100) if total_revenue > 0 else 0
# Распределение по типу оплаты
payment_dist = await db.execute(
select(
WheelSpin.payment_type,
func.count(WheelSpin.id).label('count'),
func.sum(WheelSpin.payment_value_kopeks).label('total'),
)
.where(and_(*conditions) if conditions else True)
.group_by(WheelSpin.payment_type)
)
spins_by_payment_type = {
row.payment_type: {'count': row.count, 'total_kopeks': row.total or 0} for row in payment_dist
}
# Распределение призов
prizes_dist = await db.execute(
select(
WheelSpin.prize_type,
WheelSpin.prize_display_name,
func.count(WheelSpin.id).label('count'),
func.sum(WheelSpin.prize_value_kopeks).label('total'),
)
.where(and_(*conditions) if conditions else True)
.group_by(WheelSpin.prize_type, WheelSpin.prize_display_name)
)
prizes_distribution = [
{
'prize_type': row.prize_type,
'display_name': row.prize_display_name,
'count': row.count,
'total_kopeks': row.total or 0,
}
for row in prizes_dist
]
# Топ выигрышей
top_wins_result = await db.execute(
select(WheelSpin)
.options(selectinload(WheelSpin.user))
.where(and_(*conditions) if conditions else True)
.where(WheelSpin.prize_value_kopeks > 0)
.order_by(desc(WheelSpin.prize_value_kopeks))
.limit(10)
)
top_wins = [
{
'user_id': spin.user_id,
'username': spin.user.username if spin.user else None,
'prize_display_name': spin.prize_display_name,
'prize_value_kopeks': spin.prize_value_kopeks,
'created_at': spin.created_at.isoformat() if spin.created_at else None,
}
for spin in top_wins_result.scalars().all()
]
# Конфигурация для сравнения
config = await get_wheel_config(db)
configured_rtp = config.rtp_percent if config else 80
return {
'total_spins': total_spins,
'total_revenue_kopeks': total_revenue,
'total_payout_kopeks': total_payout,
'actual_rtp_percent': round(actual_rtp, 2),
'configured_rtp_percent': configured_rtp,
'spins_by_payment_type': spins_by_payment_type,
'prizes_distribution': prizes_distribution,
'top_wins': top_wins,
'period_from': date_from.isoformat() if date_from else None,
'period_to': date_to.isoformat() if date_to else None,
}