Files
remnawave-bedolaga-telegram…/app/database/crud/wheel.py
T
Fringg eb18994b7d fix: complete datetime.utcnow() → datetime.now(UTC) migration
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC)
- Migrate 30+ datetime.now() without UTC to datetime.now(UTC)
- Convert all 170 DateTime columns to DateTime(timezone=True)
- Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety
- Remove 70+ .replace(tzinfo=None) workarounds
- Fix utcfromtimestamp → fromtimestamp(..., tz=UTC)
- Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics)
- Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo)
- Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics)
- Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring)
- Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC)
- Rename panel_datetime_to_naive_utc → panel_datetime_to_utc
- Remove DTZ003 from ruff ignore list
2026-02-17 04:45:40 +03:00

406 lines
13 KiB
Python

"""
CRUD операции для колеса удачи (Fortune Wheel).
"""
from datetime import UTC, datetime
from typing import Any
import structlog
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 = structlog.get_logger(__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.now(UTC)
await db.commit()
await db.refresh(config)
logger.info('🎡 Обновлена конфигурация колеса', kwargs=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('🎁 Создан приз колеса', display_name=display_name, prize_type=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.now(UTC)
await db.commit()
await db.refresh(prize)
logger.info('🎁 Обновлен приз колеса ID', prize_id=prize_id, kwargs=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('🗑️ Удален приз колеса ID', prize_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('🔄 Переупорядочены призы колеса', prize_ids=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.now(UTC) if is_applied else None,
)
db.add(spin)
await db.commit()
await db.refresh(spin)
logger.info('🎰 Создан спин колеса: user_id=, prize', user_id=user_id, prize_display_name=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.now(UTC)
await db.commit()
await db.refresh(spin)
return spin
async def get_user_spins_today(db: AsyncSession, user_id: int) -> int:
"""Получить количество спинов пользователя за сегодня."""
today_start = datetime.now(UTC).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,
}