Merge pull request #2208 from Gy9vin/fix
Исправление уязвимостей и багов в конкурсах
This commit is contained in:
@@ -14,6 +14,7 @@ ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID=133 # Опционально: ID топика для уведомлений о чеках NaloGO
|
||||
# Автоматические отчеты
|
||||
ADMIN_REPORTS_ENABLED=false
|
||||
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
@@ -38,6 +39,7 @@ CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
|
||||
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
@@ -101,6 +103,9 @@ REMNAWAVE_USER_DELETE_MODE=delete
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
# Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным
|
||||
# Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты.
|
||||
# TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости)
|
||||
TRIAL_PAYMENT_ENABLED=false
|
||||
TRIAL_ACTIVATION_PRICE=0
|
||||
|
||||
@@ -132,6 +137,26 @@ TRAFFIC_SELECTION_MODE=selectable
|
||||
# 0 = безлимит
|
||||
FIXED_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ДОКУПКА ТРАФИКА =====
|
||||
# Включить/выключить функцию докупки трафика к существующей подписке
|
||||
TRAFFIC_TOPUP_ENABLED=true
|
||||
|
||||
# Пакеты для докупки трафика (формат: "гб:цена_в_копейках:enabled")
|
||||
# Пустая строка = использовать TRAFFIC_PACKAGES_CONFIG
|
||||
# Пример: "10:5000:true,25:10000:true,50:15000:true,100:25000:true"
|
||||
TRAFFIC_TOPUP_PACKAGES_CONFIG=
|
||||
|
||||
# ===== СБРОС ТРАФИКА =====
|
||||
# Режим расчета цены сброса трафика:
|
||||
# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение, может быть абьюзом!)
|
||||
# "traffic" - цена = стоимость текущего пакета трафика подписки
|
||||
# "traffic_with_purchased" - цена = стоимость базового + докупленного трафика (рекомендуется)
|
||||
TRAFFIC_RESET_PRICE_MODE=traffic_with_purchased
|
||||
|
||||
# Базовая цена сброса в копейках (0 = использовать PERIOD_PRICES[30])
|
||||
# Используется как минимальная цена или фиксированная в режиме "period"
|
||||
TRAFFIC_RESET_BASE_PRICE=0
|
||||
|
||||
# ===== ПЕРИОДЫ ПОДПИСКИ =====
|
||||
# Доступные периоды подписки (через запятую)
|
||||
# Возможные значения: 14,30,60,90,180,360
|
||||
@@ -174,6 +199,16 @@ PRICE_PER_DEVICE=10000
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
|
||||
# ===== МОДЕМ =====
|
||||
# Включить функционал подключения модема
|
||||
MODEM_ENABLED=false
|
||||
# Цена модема в копейках за месяц (добавляется к ежемесячному платежу)
|
||||
MODEM_PRICE_PER_MONTH=10000
|
||||
# Скидки на модем за длительный срок: "месяцев:процент,месяцев:процент"
|
||||
# Пример: 3 мес = 15%, 6 мес = 20%, 12 мес = 25%
|
||||
MODEM_PERIOD_DISCOUNTS=3:15,6:20,12:25
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW=false
|
||||
|
||||
@@ -287,6 +322,9 @@ NALOGO_INN= # ИНН самозанятого
|
||||
NALOGO_PASSWORD= # Пароль от личного кабинета налоговой
|
||||
NALOGO_DEVICE_ID= # Опционально: ID устройства для авторизации
|
||||
NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токенами
|
||||
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
@@ -460,6 +498,12 @@ DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
|
||||
# true: 14.78₽ → 15₽, 14.12₽ → 14₽
|
||||
# false: показывать точные суммы с копейками
|
||||
PRICE_ROUNDING_ENABLED=true
|
||||
|
||||
# Часовой пояс
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
@@ -513,6 +557,13 @@ BOT_RUN_MODE=polling # polling, webhook или both
|
||||
CONTESTS_ENABLED=false
|
||||
CONTESTS_BUTTON_VISIBLE=false
|
||||
|
||||
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# Автоматическая покупка из сохранённой корзины после пополнения баланса
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
|
||||
# Умная автоактивация: система сама решает — продлить или создать подписку
|
||||
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
# ACTIVATE_BUTTON_TEXT=активировать
|
||||
|
||||
+183
-7
@@ -45,6 +45,12 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: Optional[int] = None
|
||||
|
||||
# Настройки очереди чеков NaloGO
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека
|
||||
|
||||
ADMIN_REPORTS_ENABLED: bool = False
|
||||
ADMIN_REPORTS_CHAT_ID: Optional[str] = None
|
||||
@@ -55,7 +61,8 @@ class Settings(BaseSettings):
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
|
||||
|
||||
CHANNEL_REQUIRED_FOR_ALL: bool = False
|
||||
|
||||
DATABASE_URL: Optional[str] = None
|
||||
|
||||
POSTGRES_HOST: str = "postgres"
|
||||
@@ -143,12 +150,31 @@ class Settings(BaseSettings):
|
||||
DEVICES_SELECTION_ENABLED: bool = True
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
|
||||
|
||||
# Настройки модема
|
||||
MODEM_ENABLED: bool = False
|
||||
MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц
|
||||
MODEM_PERIOD_DISCOUNTS: str = "" # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20")
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
|
||||
|
||||
TRAFFIC_SELECTION_MODE: str = "selectable"
|
||||
FIXED_TRAFFIC_LIMIT_GB: int = 100
|
||||
BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
|
||||
BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
|
||||
|
||||
# Настройки докупки трафика
|
||||
TRAFFIC_TOPUP_ENABLED: bool = True # Включить/выключить функцию докупки трафика
|
||||
# Пакеты для докупки трафика (формат: "гб:цена:enabled", пустая строка = использовать TRAFFIC_PACKAGES_CONFIG)
|
||||
TRAFFIC_TOPUP_PACKAGES_CONFIG: str = ""
|
||||
|
||||
# Настройки сброса трафика
|
||||
# Режимы расчета цены сброса:
|
||||
# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение)
|
||||
# "traffic" - цена зависит от текущего лимита трафика (цена пакета трафика)
|
||||
# "traffic_with_purchased" - цена = базовый трафик + докупленный трафик (рекомендуется)
|
||||
TRAFFIC_RESET_PRICE_MODE: str = "traffic_with_purchased"
|
||||
# Базовая цена сброса в копейках (используется если режим "period" или как минимальная цена)
|
||||
TRAFFIC_RESET_BASE_PRICE: int = 0 # 0 = использовать PERIOD_PRICES[30]
|
||||
|
||||
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
|
||||
@@ -244,6 +270,7 @@ class Settings(BaseSettings):
|
||||
NALOGO_STORAGE_PATH: str = "./nalogo_tokens.json"
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW: bool = False
|
||||
@@ -393,7 +420,10 @@ class Settings(BaseSettings):
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
|
||||
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
|
||||
PRICE_ROUNDING_ENABLED: bool = True
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/bot.log"
|
||||
|
||||
@@ -762,7 +792,17 @@ class Settings(BaseSettings):
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
|
||||
def is_auto_activate_after_topup_enabled(self) -> bool:
|
||||
"""Умная автоактивация после пополнения баланса (без корзины)."""
|
||||
value = getattr(self, "AUTO_ACTIVATE_AFTER_TOPUP_ENABLED", False)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
defaults = ["ru", "en", "ua", "zh"]
|
||||
|
||||
@@ -802,10 +842,32 @@ class Settings(BaseSettings):
|
||||
def is_language_selection_enabled(self) -> bool:
|
||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
sign = "-" if price_kopeks < 0 else ""
|
||||
rubles, kopeks = divmod(abs(price_kopeks), 100)
|
||||
def format_price(self, price_kopeks: int, round_kopeks: Optional[bool] = None) -> str:
|
||||
"""
|
||||
Форматирует цену в копейках для отображения пользователю.
|
||||
|
||||
Args:
|
||||
price_kopeks: Сумма в копейках
|
||||
round_kopeks: Если True, округляет копейки (≤50 вниз, >50 вверх).
|
||||
Если None, использует настройку PRICE_ROUNDING_ENABLED.
|
||||
|
||||
Returns:
|
||||
Отформатированная строка цены (например, "150 ₽")
|
||||
"""
|
||||
# Используем настройку если не передано явно
|
||||
should_round = round_kopeks if round_kopeks is not None else self.PRICE_ROUNDING_ENABLED
|
||||
|
||||
sign = "-" if price_kopeks < 0 else ""
|
||||
abs_kopeks = abs(price_kopeks)
|
||||
rubles, kopeks = divmod(abs_kopeks, 100)
|
||||
|
||||
if should_round:
|
||||
# Округление: ≤50 коп вниз, >50 коп вверх
|
||||
if kopeks > 50:
|
||||
rubles += 1
|
||||
return f"{sign}{rubles} ₽"
|
||||
|
||||
# Без округления - показываем точное значение
|
||||
if kopeks:
|
||||
value = f"{sign}{rubles}.{kopeks:02d}".rstrip("0").rstrip(".")
|
||||
return f"{value} ₽"
|
||||
@@ -986,6 +1048,57 @@ class Settings(BaseSettings):
|
||||
def get_fixed_traffic_limit(self) -> int:
|
||||
return self.FIXED_TRAFFIC_LIMIT_GB
|
||||
|
||||
def is_traffic_topup_enabled(self) -> bool:
|
||||
return self.TRAFFIC_TOPUP_ENABLED
|
||||
|
||||
def get_traffic_topup_packages(self) -> List[Dict]:
|
||||
"""Возвращает пакеты для докупки трафика. Если не настроены - использует TRAFFIC_PACKAGES_CONFIG."""
|
||||
config_str = self.TRAFFIC_TOPUP_PACKAGES_CONFIG.strip()
|
||||
|
||||
if not config_str:
|
||||
# Если не настроены отдельные пакеты для докупки - используем основные
|
||||
return self.get_traffic_packages()
|
||||
|
||||
packages = []
|
||||
for package_config in config_str.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
continue
|
||||
|
||||
parts = package_config.split(':')
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
gb = int(parts[0])
|
||||
price = int(parts[1])
|
||||
enabled = parts[2].lower() == 'true' if len(parts) > 2 else True
|
||||
packages.append({"gb": gb, "price": price, "enabled": enabled})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return packages if packages else self.get_traffic_packages()
|
||||
|
||||
def get_traffic_topup_price(self, gb: Optional[int]) -> int:
|
||||
"""Возвращает цену докупки для указанного количества ГБ."""
|
||||
packages = self.get_traffic_topup_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
|
||||
if not enabled_packages:
|
||||
return 0
|
||||
|
||||
# Ищем точное совпадение
|
||||
for pkg in enabled_packages:
|
||||
if pkg["gb"] == gb:
|
||||
return pkg["price"]
|
||||
|
||||
# Если не нашли - возвращаем 0
|
||||
return 0
|
||||
|
||||
def get_traffic_reset_price_mode(self) -> str:
|
||||
return self.TRAFFIC_RESET_PRICE_MODE.lower()
|
||||
|
||||
def get_traffic_reset_base_price(self) -> int:
|
||||
return self.TRAFFIC_RESET_BASE_PRICE
|
||||
|
||||
def is_devices_selection_enabled(self) -> bool:
|
||||
return self.DEVICES_SELECTION_ENABLED
|
||||
|
||||
@@ -1012,7 +1125,70 @@ class Settings(BaseSettings):
|
||||
def get_disabled_mode_device_limit(self) -> Optional[int]:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_modem_enabled(self) -> bool:
|
||||
return bool(self.MODEM_ENABLED)
|
||||
|
||||
def get_modem_price_per_month(self) -> int:
|
||||
try:
|
||||
value = int(self.MODEM_PRICE_PER_MONTH)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Некорректное значение MODEM_PRICE_PER_MONTH: %s",
|
||||
self.MODEM_PRICE_PER_MONTH,
|
||||
)
|
||||
return 10000
|
||||
return max(0, value)
|
||||
|
||||
def get_modem_period_discounts(self) -> Dict[int, int]:
|
||||
"""Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}"""
|
||||
try:
|
||||
config_str = (self.MODEM_PERIOD_DISCOUNTS or "").strip()
|
||||
if not config_str:
|
||||
return {}
|
||||
|
||||
discounts: Dict[int, int] = {}
|
||||
for part in config_str.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
months_and_discount = part.split(':')
|
||||
if len(months_and_discount) != 2:
|
||||
continue
|
||||
|
||||
months_str, discount_str = months_and_discount
|
||||
try:
|
||||
months = int(months_str.strip())
|
||||
discount_percent = int(discount_str.strip())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
discounts[months] = max(0, min(100, discount_percent))
|
||||
|
||||
return discounts
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_modem_period_discount(self, months: int) -> int:
|
||||
"""Возвращает процент скидки для указанного количества месяцев"""
|
||||
if months <= 0:
|
||||
return 0
|
||||
|
||||
discounts = self.get_modem_period_discounts()
|
||||
|
||||
# Ищем точное совпадение или ближайшее меньшее
|
||||
applicable_discount = 0
|
||||
for discount_months, discount_percent in sorted(discounts.items()):
|
||||
if months >= discount_months:
|
||||
applicable_discount = discount_percent
|
||||
|
||||
return applicable_discount
|
||||
|
||||
def is_trial_paid_activation_enabled(self) -> bool:
|
||||
# Если цена > 0, триал автоматически платный
|
||||
# (TRIAL_PAYMENT_ENABLED теперь опционален - для обратной совместимости)
|
||||
if self.TRIAL_ACTIVATION_PRICE > 0:
|
||||
return True
|
||||
return bool(self.TRIAL_PAYMENT_ENABLED)
|
||||
|
||||
def get_trial_activation_price(self) -> int:
|
||||
|
||||
@@ -40,7 +40,8 @@ async def upsert_template(
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
prize_days: int = 1,
|
||||
prize_type: str = "days",
|
||||
prize_value: str = "1",
|
||||
max_winners: int = 1,
|
||||
attempts_per_user: int = 1,
|
||||
times_per_day: int = 1,
|
||||
@@ -56,7 +57,8 @@ async def upsert_template(
|
||||
|
||||
template.name = name
|
||||
template.description = description
|
||||
template.prize_days = prize_days
|
||||
template.prize_type = prize_type
|
||||
template.prize_value = prize_value
|
||||
template.max_winners = max_winners
|
||||
template.attempts_per_user = attempts_per_user
|
||||
template.times_per_day = times_per_day
|
||||
@@ -189,6 +191,22 @@ async def create_attempt(
|
||||
return attempt
|
||||
|
||||
|
||||
async def update_attempt(
|
||||
db: AsyncSession,
|
||||
attempt: ContestAttempt,
|
||||
*,
|
||||
answer: Optional[str] = None,
|
||||
is_winner: bool = False,
|
||||
) -> ContestAttempt:
|
||||
"""Update existing attempt with answer and winner status."""
|
||||
if answer is not None:
|
||||
attempt.answer = answer
|
||||
attempt.is_winner = is_winner
|
||||
await db.commit()
|
||||
await db.refresh(attempt)
|
||||
return attempt
|
||||
|
||||
|
||||
async def clear_attempts(db: AsyncSession, round_id: int) -> int:
|
||||
result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id))
|
||||
deleted_count = result.rowcount
|
||||
|
||||
@@ -269,6 +269,176 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def get_top_referrers_by_period(
|
||||
db: AsyncSession,
|
||||
period: str = "week", # "week" или "month"
|
||||
sort_by: str = "earnings", # "earnings" или "invited"
|
||||
limit: int = 20
|
||||
) -> list:
|
||||
"""
|
||||
Получает топ рефереров за период.
|
||||
|
||||
Args:
|
||||
period: "week" (7 дней) или "month" (30 дней)
|
||||
sort_by: "earnings" (по заработку) или "invited" (по приглашённым)
|
||||
limit: количество записей
|
||||
|
||||
Returns:
|
||||
Список словарей с данными рефереров
|
||||
"""
|
||||
from app.database.models import Transaction, TransactionType
|
||||
|
||||
now = datetime.utcnow()
|
||||
if period == "week":
|
||||
start_date = now - timedelta(days=7)
|
||||
else: # month
|
||||
start_date = now - timedelta(days=30)
|
||||
|
||||
if sort_by == "invited":
|
||||
# Топ по количеству приглашённых за период
|
||||
referrals_result = await db.execute(
|
||||
select(
|
||||
User.referred_by_id.label('referrer_id'),
|
||||
func.count(User.id).label('invited_count')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id.isnot(None),
|
||||
User.created_at >= start_date
|
||||
)
|
||||
)
|
||||
.group_by(User.referred_by_id)
|
||||
.order_by(func.count(User.id).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
top_data = []
|
||||
for row in referrals_result:
|
||||
# Получаем заработок за период для этого реферера
|
||||
earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
ReferralEarning.user_id == row.referrer_id,
|
||||
ReferralEarning.created_at >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
earnings = earnings_result.scalar() or 0
|
||||
|
||||
# Добавляем транзакции REFERRAL_REWARD
|
||||
trans_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id == row.referrer_id,
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
earnings += trans_earnings_result.scalar() or 0
|
||||
|
||||
top_data.append({
|
||||
'referrer_id': row.referrer_id,
|
||||
'invited_count': row.invited_count,
|
||||
'earnings_kopeks': earnings
|
||||
})
|
||||
else:
|
||||
# Топ по заработку за период
|
||||
# Собираем заработки из ReferralEarning
|
||||
referral_earnings_result = await db.execute(
|
||||
select(
|
||||
ReferralEarning.user_id.label('referrer_id'),
|
||||
func.sum(ReferralEarning.amount_kopeks).label('ref_earnings')
|
||||
)
|
||||
.where(ReferralEarning.created_at >= start_date)
|
||||
.group_by(ReferralEarning.user_id)
|
||||
)
|
||||
referral_earnings = {row.referrer_id: row.ref_earnings for row in referral_earnings_result}
|
||||
|
||||
# Добавляем транзакции REFERRAL_REWARD
|
||||
transaction_earnings_result = await db.execute(
|
||||
select(
|
||||
Transaction.user_id.label('referrer_id'),
|
||||
func.sum(Transaction.amount_kopeks).label('trans_earnings')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= start_date
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
|
||||
# Объединяем заработки
|
||||
combined_earnings = dict(referral_earnings)
|
||||
for row in transaction_earnings_result:
|
||||
if row.referrer_id in combined_earnings:
|
||||
combined_earnings[row.referrer_id] += row.trans_earnings or 0
|
||||
else:
|
||||
combined_earnings[row.referrer_id] = row.trans_earnings or 0
|
||||
|
||||
# Сортируем и берём топ
|
||||
sorted_referrers = sorted(
|
||||
combined_earnings.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)[:limit]
|
||||
|
||||
top_data = []
|
||||
for referrer_id, earnings in sorted_referrers:
|
||||
# Получаем количество приглашённых за период
|
||||
invited_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == referrer_id,
|
||||
User.created_at >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
invited_count = invited_result.scalar() or 0
|
||||
|
||||
top_data.append({
|
||||
'referrer_id': referrer_id,
|
||||
'invited_count': invited_count,
|
||||
'earnings_kopeks': earnings
|
||||
})
|
||||
|
||||
# Добавляем информацию о пользователях
|
||||
result = []
|
||||
for data in top_data:
|
||||
user_result = await db.execute(
|
||||
select(User.id, User.username, User.first_name, User.last_name, User.telegram_id)
|
||||
.where(User.id == data['referrer_id'])
|
||||
)
|
||||
user = user_result.first()
|
||||
|
||||
if user:
|
||||
display_name = ""
|
||||
if user.first_name:
|
||||
display_name = user.first_name
|
||||
if user.last_name:
|
||||
display_name += f" {user.last_name}"
|
||||
elif user.username:
|
||||
display_name = f"@{user.username}"
|
||||
else:
|
||||
display_name = f"ID{user.telegram_id}"
|
||||
|
||||
result.append({
|
||||
'user_id': user.id,
|
||||
'telegram_id': user.telegram_id,
|
||||
'username': user.username,
|
||||
'display_name': display_name,
|
||||
'invited_count': data['invited_count'],
|
||||
'earnings_kopeks': data['earnings_kopeks']
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
|
||||
invited_count_result = await db.execute(
|
||||
|
||||
@@ -128,17 +128,18 @@ async def create_paid_subscription(
|
||||
device_limit: Optional[int] = None,
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
is_trial: bool = False,
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=False,
|
||||
is_trial=is_trial,
|
||||
start_date=datetime.utcnow(),
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
@@ -222,6 +223,7 @@ async def replace_subscription(
|
||||
subscription.end_date = current_time + timedelta(days=duration_days)
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
subscription.traffic_used_gb = 0.0
|
||||
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки
|
||||
subscription.device_limit = device_limit
|
||||
subscription.connected_squads = list(new_squads)
|
||||
subscription.subscription_url = None
|
||||
@@ -339,7 +341,8 @@ async def extend_subscription(
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
|
||||
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик вместе с использованным
|
||||
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
@@ -1545,7 +1548,84 @@ async def create_pending_subscription(
|
||||
subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_pending_trial_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None,
|
||||
payment_method: str = "pending",
|
||||
total_price_kopeks: int = 0
|
||||
) -> Subscription:
|
||||
"""Creates a pending trial subscription that will be activated after payment."""
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
end_date = current_time + timedelta(days=duration_days)
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
if existing_subscription:
|
||||
if (
|
||||
existing_subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and existing_subscription.end_date > current_time
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Попытка создать pending триал для активного пользователя %s. Возвращаем существующую запись.",
|
||||
user_id,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
# Обновляем существующую подписку
|
||||
existing_subscription.status = SubscriptionStatus.PENDING.value
|
||||
existing_subscription.is_trial = True # Помечаем как триальную
|
||||
existing_subscription.start_date = current_time
|
||||
existing_subscription.end_date = end_date
|
||||
existing_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
existing_subscription.device_limit = device_limit
|
||||
existing_subscription.connected_squads = connected_squads or []
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
existing_subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
logger.info(
|
||||
"♻️ Обновлена ожидающая триальная подписка пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
existing_subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.PENDING.value,
|
||||
is_trial=True, # Помечаем как триальную
|
||||
start_date=current_time,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
"💳 Создана ожидающая триальная подписка для пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
|
||||
@@ -331,13 +331,14 @@ async def add_user_balance(
|
||||
description: str = "Пополнение баланса",
|
||||
create_transaction: bool = True,
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
bot = None
|
||||
bot = None,
|
||||
payment_method: Optional[PaymentMethod] = None
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
|
||||
@@ -346,7 +347,8 @@ async def add_user_balance(
|
||||
user_id=user.id,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
description=description,
|
||||
payment_method=payment_method
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
@@ -368,19 +370,21 @@ async def add_user_balance_by_id(
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
payment_method: Optional[PaymentMethod] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
|
||||
return False
|
||||
|
||||
|
||||
return await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
description,
|
||||
transaction_type=transaction_type,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -755,11 +755,13 @@ class Subscription(Base):
|
||||
|
||||
traffic_limit_gb = Column(Integer, default=0)
|
||||
traffic_used_gb = Column(Float, default=0.0)
|
||||
purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик (для расчета цены сброса)
|
||||
|
||||
subscription_url = Column(String, nullable=True)
|
||||
subscription_crypto_link = Column(String, nullable=True)
|
||||
|
||||
device_limit = Column(Integer, default=1)
|
||||
modem_enabled = Column(Boolean, default=False)
|
||||
|
||||
connected_squads = Column(JSON, default=list)
|
||||
|
||||
@@ -1107,7 +1109,8 @@ class ContestTemplate(Base):
|
||||
name = Column(String(100), nullable=False)
|
||||
slug = Column(String(50), nullable=False, unique=True, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
prize_days = Column(Integer, nullable=False, default=1)
|
||||
prize_type = Column(String(20), nullable=False, default="days")
|
||||
prize_value = Column(String(50), nullable=False, default="1")
|
||||
max_winners = Column(Integer, nullable=False, default=1)
|
||||
attempts_per_user = Column(Integer, nullable=False, default=1)
|
||||
times_per_day = Column(Integer, nullable=False, default=1)
|
||||
|
||||
@@ -4555,6 +4555,130 @@ async def add_promocode_promo_group_column() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def migrate_contest_templates_prize_columns() -> bool:
|
||||
"""Миграция contest_templates: prize_days -> prize_type + prize_value."""
|
||||
try:
|
||||
prize_type_exists = await check_column_exists("contest_templates", "prize_type")
|
||||
prize_value_exists = await check_column_exists("contest_templates", "prize_value")
|
||||
|
||||
if prize_type_exists and prize_value_exists:
|
||||
logger.info("Колонки prize_type и prize_value уже существуют в contest_templates")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
# Добавляем prize_type
|
||||
if not prize_type_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
|
||||
))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
|
||||
))
|
||||
else:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
|
||||
))
|
||||
logger.info("✅ Добавлена колонка prize_type в contest_templates")
|
||||
|
||||
# Добавляем prize_value
|
||||
if not prize_value_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
|
||||
))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
|
||||
))
|
||||
else:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
|
||||
))
|
||||
logger.info("✅ Добавлена колонка prize_value в contest_templates")
|
||||
|
||||
# Мигрируем данные из prize_days в prize_value (если prize_days существует)
|
||||
prize_days_exists = await check_column_exists("contest_templates", "prize_days")
|
||||
if prize_days_exists:
|
||||
await conn.execute(text(
|
||||
"UPDATE contest_templates SET prize_value = CAST(prize_days AS VARCHAR) WHERE prize_type = 'days'"
|
||||
))
|
||||
logger.info("✅ Данные из prize_days перенесены в prize_value")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка миграции prize_type/prize_value в contest_templates: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_subscription_modem_enabled_column() -> bool:
|
||||
"""Добавить колонку modem_enabled в subscriptions."""
|
||||
try:
|
||||
column_exists = await check_column_exists("subscriptions", "modem_enabled")
|
||||
if column_exists:
|
||||
logger.info("Колонка modem_enabled уже существует в subscriptions")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT 0"
|
||||
))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT FALSE"
|
||||
))
|
||||
else:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN modem_enabled TINYINT(1) DEFAULT 0"
|
||||
))
|
||||
|
||||
logger.info("✅ Добавлена колонка modem_enabled в subscriptions")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления modem_enabled в subscriptions: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_subscription_purchased_traffic_column() -> bool:
|
||||
"""Добавить колонку purchased_traffic_gb в subscriptions."""
|
||||
try:
|
||||
column_exists = await check_column_exists("subscriptions", "purchased_traffic_gb")
|
||||
if column_exists:
|
||||
logger.info("Колонка purchased_traffic_gb уже существует в subscriptions")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0"
|
||||
))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0"
|
||||
))
|
||||
else:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INT DEFAULT 0"
|
||||
))
|
||||
|
||||
logger.info("✅ Добавлена колонка purchased_traffic_gb в subscriptions")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления purchased_traffic_gb в subscriptions: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -4742,6 +4866,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей contest_templates")
|
||||
|
||||
logger.info("=== МИГРАЦИЯ КОЛОНОК ПРИЗА В CONTEST_TEMPLATES ===")
|
||||
prize_columns_ready = await migrate_contest_templates_prize_columns()
|
||||
if prize_columns_ready:
|
||||
logger.info("✅ Колонки prize_type и prize_value готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с миграцией prize_type/prize_value")
|
||||
|
||||
contest_rounds_ready = await create_contest_rounds_table()
|
||||
if contest_rounds_ready:
|
||||
logger.info("✅ Таблица contest_rounds готова")
|
||||
@@ -4897,6 +5028,20 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ MODEM_ENABLED ДЛЯ ПОДПИСОК ===")
|
||||
modem_enabled_added = await add_subscription_modem_enabled_column()
|
||||
if modem_enabled_added:
|
||||
logger.info("✅ Колонка modem_enabled готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением колонки modem_enabled")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ PURCHASED_TRAFFIC_GB ДЛЯ ПОДПИСОК ===")
|
||||
purchased_traffic_added = await add_subscription_purchased_traffic_column()
|
||||
if purchased_traffic_added:
|
||||
logger.info("✅ Колонка purchased_traffic_gb готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением колонки purchased_traffic_gb")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -5079,6 +5224,10 @@ async def check_migration_status():
|
||||
"users_promo_offer_discount_expires_column": False,
|
||||
"users_referral_commission_percent_column": False,
|
||||
"subscription_crypto_link_column": False,
|
||||
"subscription_modem_enabled_column": False,
|
||||
"subscription_purchased_traffic_column": False,
|
||||
"contest_templates_prize_type_column": False,
|
||||
"contest_templates_prize_value_column": False,
|
||||
"discount_offers_table": False,
|
||||
"discount_offers_effect_column": False,
|
||||
"discount_offers_extra_column": False,
|
||||
@@ -5139,7 +5288,11 @@ async def check_migration_status():
|
||||
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
|
||||
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
|
||||
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
|
||||
|
||||
status["subscription_modem_enabled_column"] = await check_column_exists('subscriptions', 'modem_enabled')
|
||||
status["subscription_purchased_traffic_column"] = await check_column_exists('subscriptions', 'purchased_traffic_gb')
|
||||
status["contest_templates_prize_type_column"] = await check_column_exists('contest_templates', 'prize_type')
|
||||
status["contest_templates_prize_value_column"] = await check_column_exists('contest_templates', 'prize_value')
|
||||
|
||||
media_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'has_media') and
|
||||
await check_column_exists('broadcast_history', 'media_type') and
|
||||
@@ -5211,6 +5364,10 @@ async def check_migration_status():
|
||||
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
|
||||
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
|
||||
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
|
||||
"subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions",
|
||||
"subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions",
|
||||
"contest_templates_prize_type_column": "Колонка prize_type в contest_templates",
|
||||
"contest_templates_prize_value_column": "Колонка prize_value в contest_templates",
|
||||
"discount_offers_table": "Таблица discount_offers",
|
||||
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
|
||||
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
|
||||
|
||||
@@ -9,10 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
clear_attempts,
|
||||
create_round,
|
||||
get_template_by_id,
|
||||
list_templates,
|
||||
update_template_fields,
|
||||
create_round,
|
||||
)
|
||||
from app.database.models import ContestTemplate
|
||||
from app.keyboards.admin import (
|
||||
@@ -28,7 +29,8 @@ from app.utils.decorators import admin_required, error_handler
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EDITABLE_FIELDS: Dict[str, Dict] = {
|
||||
"prize_days": {"type": int, "min": 1, "label": "приз (дни)"},
|
||||
"prize_type": {"type": str, "label": "тип приза (days/balance/custom)"},
|
||||
"prize_value": {"type": str, "label": "значение приза"},
|
||||
"max_winners": {"type": int, "min": 1, "label": "макс. победителей"},
|
||||
"attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"},
|
||||
"times_per_day": {"type": int, "min": 1, "label": "раундов в день"},
|
||||
@@ -57,7 +59,8 @@ async def show_daily_contests(
|
||||
else:
|
||||
for tpl in templates:
|
||||
status = "🟢" if tpl.is_enabled else "⚪️"
|
||||
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {tpl.max_winners}")
|
||||
prize_info = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
|
||||
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {prize_info}, макс {tpl.max_winners}")
|
||||
|
||||
keyboard_rows = []
|
||||
if templates:
|
||||
@@ -101,10 +104,12 @@ async def show_daily_contest(
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
prize_display = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
|
||||
lines = [
|
||||
f"🏷 <b>{tpl.name}</b> (slug: {tpl.slug})",
|
||||
f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}",
|
||||
f"Приз: {tpl.prize_days} дн. | Макс победителей: {tpl.max_winners}",
|
||||
f"Тип приза: {tpl.prize_type or 'days'} | Значение: {tpl.prize_value or '1'}",
|
||||
f"Макс победителей: {tpl.max_winners}",
|
||||
f"Попыток/польз: {tpl.attempts_per_user}",
|
||||
f"Раундов в день: {tpl.times_per_day}",
|
||||
f"Расписание: {tpl.schedule_times or '-'}",
|
||||
|
||||
@@ -10,6 +10,7 @@ from aiogram.exceptions import TelegramBadRequest
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.services.nalogo_queue_service import nalogo_queue_service
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
@@ -911,11 +912,36 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
|
||||
• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
|
||||
"""
|
||||
|
||||
|
||||
# Добавляем информацию о чеках NaloGO
|
||||
if settings.is_nalogo_enabled():
|
||||
nalogo_status = await nalogo_queue_service.get_status()
|
||||
queue_len = nalogo_status.get("queue_length", 0)
|
||||
total_amount = nalogo_status.get("total_amount", 0)
|
||||
running = nalogo_status.get("running", False)
|
||||
|
||||
nalogo_section = f"""
|
||||
🧾 <b>Чеки NaloGO:</b>
|
||||
• Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
|
||||
• В очереди: {queue_len} чек(ов)"""
|
||||
if queue_len > 0:
|
||||
nalogo_section += f"\n• На сумму: {total_amount:,.2f} ₽"
|
||||
text += nalogo_section
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
buttons = []
|
||||
# Кнопка обработки очереди чеков если есть что обрабатывать
|
||||
if settings.is_nalogo_enabled():
|
||||
nalogo_status = await nalogo_queue_service.get_status()
|
||||
if nalogo_status.get("queue_length", 0) > 0:
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=f"🧾 Отправить чеки ({nalogo_status['queue_length']} шт.)",
|
||||
callback_data="admin_mon_nalogo_force_process"
|
||||
)])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
@@ -925,6 +951,110 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_nalogo_force_process")
|
||||
@admin_required
|
||||
async def nalogo_force_process_callback(callback: CallbackQuery):
|
||||
"""Принудительная отправка чеков из очереди."""
|
||||
try:
|
||||
await callback.answer("🔄 Запускаю обработку очереди чеков...", show_alert=False)
|
||||
|
||||
result = await nalogo_queue_service.force_process()
|
||||
|
||||
if "error" in result:
|
||||
await callback.answer(f"❌ {result['error']}", show_alert=True)
|
||||
return
|
||||
|
||||
message = result.get("message", "Готово")
|
||||
processed = result.get("processed", 0)
|
||||
remaining = result.get("remaining", 0)
|
||||
|
||||
if processed > 0:
|
||||
text = f"✅ Обработано: {processed} чек(ов)"
|
||||
if remaining > 0:
|
||||
text += f"\n⏳ Осталось в очереди: {remaining}"
|
||||
else:
|
||||
if remaining > 0:
|
||||
text = f"⚠️ Сервис nalog.ru недоступен\n⏳ В очереди: {remaining} чек(ов)"
|
||||
else:
|
||||
text = "📭 Очередь пуста"
|
||||
|
||||
await callback.answer(text, show_alert=True)
|
||||
|
||||
# Обновляем страницу статистики
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
# Перезагружаем статистику
|
||||
async for db in get_db():
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
mon_status = await monitoring_service.get_monitoring_status(db)
|
||||
|
||||
week_ago = datetime.now() - timedelta(days=7)
|
||||
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
|
||||
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
|
||||
week_success = sum(1 for log in week_logs if log['is_success'])
|
||||
week_errors = len(week_logs) - week_success
|
||||
|
||||
stats_text = f"""
|
||||
📊 <b>Статистика мониторинга</b>
|
||||
|
||||
📱 <b>Подписки:</b>
|
||||
• Всего: {sub_stats['total_subscriptions']}
|
||||
• Активных: {sub_stats['active_subscriptions']}
|
||||
• Тестовых: {sub_stats['trial_subscriptions']}
|
||||
• Платных: {sub_stats['paid_subscriptions']}
|
||||
|
||||
📈 <b>За сегодня:</b>
|
||||
• Успешных операций: {mon_status['stats_24h']['successful']}
|
||||
• Ошибок: {mon_status['stats_24h']['failed']}
|
||||
• Успешность: {mon_status['stats_24h']['success_rate']}%
|
||||
|
||||
📊 <b>За неделю:</b>
|
||||
• Всего событий: {len(week_logs)}
|
||||
• Успешных: {week_success}
|
||||
• Ошибок: {week_errors}
|
||||
• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
|
||||
|
||||
🔧 <b>Система:</b>
|
||||
• Интервал: {settings.MONITORING_INTERVAL} мин
|
||||
• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
|
||||
• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
|
||||
"""
|
||||
|
||||
if settings.is_nalogo_enabled():
|
||||
nalogo_status = await nalogo_queue_service.get_status()
|
||||
queue_len = nalogo_status.get("queue_length", 0)
|
||||
total_amount = nalogo_status.get("total_amount", 0)
|
||||
running = nalogo_status.get("running", False)
|
||||
|
||||
nalogo_section = f"""
|
||||
🧾 <b>Чеки NaloGO:</b>
|
||||
• Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
|
||||
• В очереди: {queue_len} чек(ов)"""
|
||||
if queue_len > 0:
|
||||
nalogo_section += f"\n• На сумму: {total_amount:,.2f} ₽"
|
||||
stats_text += nalogo_section
|
||||
|
||||
buttons = []
|
||||
if settings.is_nalogo_enabled():
|
||||
nalogo_status = await nalogo_queue_service.get_status()
|
||||
if nalogo_status.get("queue_length", 0) > 0:
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=f"🧾 Отправить чеки ({nalogo_status['queue_length']} шт.)",
|
||||
callback_data="admin_mon_nalogo_force_process"
|
||||
)])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
await callback.message.edit_text(stats_text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка принудительной обработки чеков: {e}")
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
+110
-24
@@ -6,7 +6,11 @@ import datetime
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.referral import get_referral_statistics, get_user_referral_stats
|
||||
from app.database.crud.referral import (
|
||||
get_referral_statistics,
|
||||
get_top_referrers_by_period,
|
||||
get_user_referral_stats,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
@@ -121,6 +125,39 @@ async def show_referral_statistics(
|
||||
await callback.answer("Произошла ошибка при загрузке статистики")
|
||||
|
||||
|
||||
def _get_top_keyboard(period: str, sort_by: str) -> types.InlineKeyboardMarkup:
|
||||
"""Создаёт клавиатуру для выбора периода и сортировки."""
|
||||
period_week = "✅ Неделя" if period == "week" else "Неделя"
|
||||
period_month = "✅ Месяц" if period == "month" else "Месяц"
|
||||
sort_earnings = "✅ По заработку" if sort_by == "earnings" else "По заработку"
|
||||
sort_invited = "✅ По приглашённым" if sort_by == "invited" else "По приглашённым"
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=period_week,
|
||||
callback_data=f"admin_top_ref:week:{sort_by}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=period_month,
|
||||
callback_data=f"admin_top_ref:month:{sort_by}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sort_earnings,
|
||||
callback_data=f"admin_top_ref:{period}:earnings"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=sort_invited,
|
||||
callback_data=f"admin_top_ref:{period}:invited"
|
||||
),
|
||||
],
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data=f"admin_top_ref:{period}:{sort_by}")],
|
||||
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
|
||||
])
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_top_referrers(
|
||||
@@ -128,27 +165,66 @@ async def show_top_referrers(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Показывает топ рефереров (по умолчанию: неделя, по заработку)."""
|
||||
await _show_top_referrers_filtered(callback, db, period="week", sort_by="earnings")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_top_referrers_filtered(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Обрабатывает выбор периода и сортировки."""
|
||||
# Парсим callback_data: admin_top_ref:period:sort_by
|
||||
parts = callback.data.split(":")
|
||||
if len(parts) != 3:
|
||||
await callback.answer("Ошибка параметров")
|
||||
return
|
||||
|
||||
period = parts[1] # week или month
|
||||
sort_by = parts[2] # earnings или invited
|
||||
|
||||
if period not in ("week", "month"):
|
||||
period = "week"
|
||||
if sort_by not in ("earnings", "invited"):
|
||||
sort_by = "earnings"
|
||||
|
||||
await _show_top_referrers_filtered(callback, db, period, sort_by)
|
||||
|
||||
|
||||
async def _show_top_referrers_filtered(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
period: str,
|
||||
sort_by: str
|
||||
):
|
||||
"""Внутренняя функция отображения топа с фильтрами."""
|
||||
try:
|
||||
stats = await get_referral_statistics(db)
|
||||
top_referrers = stats.get('top_referrers', [])
|
||||
|
||||
text = "🏆 <b>Топ рефереров</b>\n\n"
|
||||
|
||||
top_referrers = await get_top_referrers_by_period(db, period=period, sort_by=sort_by)
|
||||
|
||||
period_text = "за неделю" if period == "week" else "за месяц"
|
||||
sort_text = "по заработку" if sort_by == "earnings" else "по приглашённым"
|
||||
|
||||
text = f"🏆 <b>Топ рефереров {period_text}</b>\n"
|
||||
text += f"<i>Сортировка: {sort_text}</i>\n\n"
|
||||
|
||||
if top_referrers:
|
||||
for i, referrer in enumerate(top_referrers[:20], 1):
|
||||
earned = referrer.get('total_earned_kopeks', 0)
|
||||
count = referrer.get('referrals_count', 0)
|
||||
for i, referrer in enumerate(top_referrers[:20], 1):
|
||||
earned = referrer.get('earnings_kopeks', 0)
|
||||
count = referrer.get('invited_count', 0)
|
||||
display_name = referrer.get('display_name', 'N/A')
|
||||
username = referrer.get('username', '')
|
||||
telegram_id = referrer.get('telegram_id', 'N/A')
|
||||
|
||||
|
||||
if username:
|
||||
display_text = f"@{username} (ID{telegram_id})"
|
||||
elif display_name and display_name != f"ID{telegram_id}":
|
||||
display_text = f"{display_name} (ID{telegram_id})"
|
||||
else:
|
||||
display_text = f"ID{telegram_id}"
|
||||
|
||||
|
||||
emoji = ""
|
||||
if i == 1:
|
||||
emoji = "🥇 "
|
||||
@@ -156,21 +232,30 @@ async def show_top_referrers(
|
||||
emoji = "🥈 "
|
||||
elif i == 3:
|
||||
emoji = "🥉 "
|
||||
|
||||
text += f"{emoji}{i}. {display_text}\n"
|
||||
text += f" 💰 {settings.format_price(earned)} | 👥 {count} реф.\n\n"
|
||||
|
||||
# Выделяем основную метрику в зависимости от сортировки
|
||||
if sort_by == "invited":
|
||||
text += f"{emoji}{i}. {display_text}\n"
|
||||
text += f" 👥 <b>{count} приглашённых</b> | 💰 {settings.format_price(earned)}\n\n"
|
||||
else:
|
||||
text += f"{emoji}{i}. {display_text}\n"
|
||||
text += f" 💰 <b>{settings.format_price(earned)}</b> | 👥 {count} приглашённых\n\n"
|
||||
else:
|
||||
text += "Нет данных о рефererах\n"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
text += "Нет данных за выбранный период\n"
|
||||
|
||||
keyboard = _get_top_keyboard(period, sort_by)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
except Exception as edit_error:
|
||||
if "message is not modified" in str(edit_error):
|
||||
await callback.answer("Данные актуальны")
|
||||
else:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_top_referrers: {e}", exc_info=True)
|
||||
logger.error(f"Ошибка в show_top_referrers_filtered: {e}", exc_info=True)
|
||||
await callback.answer("Ошибка загрузки топа рефереров")
|
||||
|
||||
|
||||
@@ -210,4 +295,5 @@ async def show_referral_settings(
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
|
||||
dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top")
|
||||
dp.callback_query.register(show_top_referrers_filtered, F.data.startswith("admin_top_ref:"))
|
||||
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
|
||||
|
||||
@@ -1241,6 +1241,15 @@ async def _render_user_subscription_overview(
|
||||
]
|
||||
]
|
||||
|
||||
if settings.is_modem_enabled():
|
||||
modem_status = "✅" if getattr(subscription, 'modem_enabled', False) else "❌"
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"📡 Модем ({modem_status})",
|
||||
callback_data=f"admin_user_modem_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if subscription.is_active:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
@@ -3803,6 +3812,68 @@ async def set_user_devices_button(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_modem(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Переключение модема для пользователя в админке."""
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
subscription = user.subscription
|
||||
if not subscription:
|
||||
await callback.answer("❌ У пользователя нет подписки", show_alert=True)
|
||||
return
|
||||
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
if modem_enabled:
|
||||
# Отключаем модем
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
action_text = "отключен"
|
||||
else:
|
||||
# Включаем модем
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
action_text = "подключен"
|
||||
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Обновляем в RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave при переключении модема: {e}")
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
modem_status = "✅ Подключен" if subscription.modem_enabled else "❌ Отключен"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📡 <b>Модем {action_text}</b>\n\n"
|
||||
f"Статус модема: {modem_status}\n"
|
||||
f"Лимит устройств: {subscription.device_limit}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_devices_edit_text(
|
||||
@@ -5198,6 +5269,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("admin_user_devices_set_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_user_modem,
|
||||
F.data.startswith("admin_user_modem_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_devices_edit_text,
|
||||
AdminStates.editing_user_devices
|
||||
|
||||
+209
-282
@@ -1,41 +1,73 @@
|
||||
"""Contest handlers for daily games."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
get_active_rounds,
|
||||
get_attempt,
|
||||
create_attempt,
|
||||
increment_winner_count,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ContestRound, ContestTemplate, SubscriptionStatus
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.contest_rotation_service import (
|
||||
GAME_QUEST,
|
||||
GAME_LOCKS,
|
||||
GAME_CIPHER,
|
||||
GAME_SERVER,
|
||||
GAME_BLITZ,
|
||||
GAME_EMOJI,
|
||||
GAME_ANAGRAM,
|
||||
)
|
||||
from app.database.crud.contest import get_active_rounds, get_attempt
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import extend_subscription
|
||||
from app.utils.decorators import auth_required, error_handler
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ContestRound, SubscriptionStatus
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.contests import (
|
||||
ContestAttemptService,
|
||||
GameType,
|
||||
get_game_strategy,
|
||||
)
|
||||
from app.states import ContestStates
|
||||
from app.utils.decorators import auth_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Rate limiting storage
|
||||
_rate_limits: dict = {}
|
||||
|
||||
# Service instance
|
||||
_attempt_service = ContestAttemptService()
|
||||
|
||||
|
||||
def _check_rate_limit(user_id: int, action: str, limit: int = 1, window_seconds: int = 5) -> bool:
|
||||
"""Check if user exceeds rate limit for contest actions."""
|
||||
key = f"{user_id}_{action}"
|
||||
now = datetime.utcnow().timestamp()
|
||||
|
||||
if key not in _rate_limits:
|
||||
_rate_limits[key] = []
|
||||
|
||||
# Clean old entries
|
||||
_rate_limits[key] = [t for t in _rate_limits[key] if now - t < window_seconds]
|
||||
|
||||
if len(_rate_limits[key]) >= limit:
|
||||
return False
|
||||
|
||||
_rate_limits[key].append(now)
|
||||
return True
|
||||
|
||||
|
||||
def _validate_callback_data(data: str) -> Optional[list]:
|
||||
"""Validate and parse callback data safely."""
|
||||
if not data or not isinstance(data, str):
|
||||
return None
|
||||
|
||||
parts = data.split("_")
|
||||
if len(parts) < 2 or parts[0] != "contest":
|
||||
return None
|
||||
|
||||
for part in parts:
|
||||
if not part or len(part) > 50:
|
||||
return None
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def _user_allowed(subscription) -> bool:
|
||||
"""Check if user has active or trial subscription."""
|
||||
if not subscription:
|
||||
return False
|
||||
return subscription.status in {
|
||||
@@ -44,22 +76,13 @@ def _user_allowed(subscription) -> bool:
|
||||
}
|
||||
|
||||
|
||||
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return ""
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
return ""
|
||||
await extend_subscription(db, subscription, prize_days)
|
||||
texts = get_texts(language)
|
||||
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
|
||||
|
||||
|
||||
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
|
||||
"""Reply that user is not eligible to play."""
|
||||
texts = get_texts(language)
|
||||
await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True)
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Handlers ----------
|
||||
@@ -68,13 +91,17 @@ async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession):
|
||||
"""Show menu with available contest games."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await _reply_not_eligible(callback, db_user.language)
|
||||
return
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
|
||||
# Group by template, take one round per template
|
||||
unique_templates = {}
|
||||
for rnd in active_rounds:
|
||||
if not rnd.template or not rnd.template.is_enabled:
|
||||
@@ -86,19 +113,24 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe
|
||||
buttons = []
|
||||
for tpl_slug, rnd in unique_templates.items():
|
||||
title = rnd.template.name if rnd.template else tpl_slug
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"▶️ {title}",
|
||||
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"▶️ {title}",
|
||||
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
|
||||
)
|
||||
])
|
||||
|
||||
if not buttons:
|
||||
buttons.append(
|
||||
[types.InlineKeyboardButton(text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), callback_data="noop")]
|
||||
)
|
||||
buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"),
|
||||
callback_data="noop",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_MENU_TITLE", "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:"),
|
||||
@@ -110,14 +142,25 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession):
|
||||
"""Start playing a specific contest."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await _reply_not_eligible(callback, db_user.language)
|
||||
return
|
||||
|
||||
parts = callback.data.split("_")
|
||||
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "play":
|
||||
# Rate limit check
|
||||
if not _check_rate_limit(db_user.id, "contest_play", limit=2, window_seconds=10):
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate callback data
|
||||
parts = _validate_callback_data(callback.data)
|
||||
if not parts or len(parts) < 4 or parts[1] != "play":
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -128,285 +171,169 @@ async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
slug = "_".join(parts[2:-1])
|
||||
|
||||
# reload round with template
|
||||
# Get round with template
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
|
||||
if not round_obj:
|
||||
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), show_alert=True)
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not round_obj.template or not round_obj.template.is_enabled:
|
||||
await callback.answer(texts.t("CONTEST_DISABLED", "Игра отключена."), show_alert=True)
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_DISABLED", "Игра отключена."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Check if user already played
|
||||
attempt = await get_attempt(db2, round_id, db_user.id)
|
||||
if attempt:
|
||||
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), show_alert=True)
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Get game strategy and render
|
||||
tpl = round_obj.template
|
||||
if tpl.slug == GAME_QUEST:
|
||||
await _render_quest(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_LOCKS:
|
||||
await _render_locks(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_SERVER:
|
||||
await _render_server_lottery(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_CIPHER:
|
||||
await _render_cipher(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_EMOJI:
|
||||
await _render_emoji(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_ANAGRAM:
|
||||
await _render_anagram(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
await _render_blitz(callback, db_user, round_obj, tpl)
|
||||
else:
|
||||
await callback.answer(texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), show_alert=True)
|
||||
strategy = get_game_strategy(tpl.slug)
|
||||
|
||||
|
||||
async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
rows = round_obj.payload.get("rows", 3)
|
||||
cols = round_obj.payload.get("cols", 3)
|
||||
keyboard = []
|
||||
for r in range(rows):
|
||||
row_buttons = []
|
||||
for c in range(cols):
|
||||
idx = r * cols + c
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="🎛",
|
||||
callback_data=f"contest_pick_{round_obj.id}_quest_{idx}"
|
||||
)
|
||||
if not strategy:
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."),
|
||||
show_alert=True,
|
||||
)
|
||||
keyboard.append(row_buttons)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
render_result = strategy.render(
|
||||
round_id=round_obj.id,
|
||||
payload=round_obj.payload or {},
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
total = round_obj.payload.get("total", 20)
|
||||
keyboard = []
|
||||
row = []
|
||||
for i in range(total):
|
||||
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}"))
|
||||
if len(row) == 5:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
# For text input games, create pending attempt and set FSM state
|
||||
if render_result.requires_text_input:
|
||||
await _attempt_service.create_pending_attempt(db2, round_obj.id, db_user.id)
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
|
||||
|
||||
async def _render_server_lottery(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
flags = round_obj.payload.get("flags") or []
|
||||
shuffled_flags = flags.copy()
|
||||
random.shuffle(shuffled_flags)
|
||||
keyboard = []
|
||||
row = []
|
||||
for flag in shuffled_flags:
|
||||
row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{flag}"))
|
||||
if len(row) == 5:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
question = round_obj.payload.get("question", "")
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_emoji(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
question = round_obj.payload.get("question", "🤔")
|
||||
emoji_list = question.split()
|
||||
random.shuffle(emoji_list)
|
||||
shuffled_question = " ".join(emoji_list)
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=shuffled_question),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_anagram(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
letters = round_obj.payload.get("letters", "")
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(letters=letters),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), callback_data=f"contest_pick_{round_obj.id}_blitz")]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
await callback.message.edit_text(
|
||||
render_result.text,
|
||||
reply_markup=render_result.keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
|
||||
"""Handle button pick in contest games."""
|
||||
texts = get_texts(db_user.language)
|
||||
parts = callback.data.split("_")
|
||||
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "pick":
|
||||
|
||||
# Rate limit check
|
||||
if not _check_rate_limit(db_user.id, "contest_pick", limit=1, window_seconds=3):
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate callback data
|
||||
parts = _validate_callback_data(callback.data)
|
||||
if not parts or len(parts) < 4 or parts[1] != "pick":
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
round_id_str = parts[2]
|
||||
pick = "_".join(parts[3:])
|
||||
|
||||
try:
|
||||
round_id = int(round_id_str)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
if not round_obj:
|
||||
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), show_alert=True)
|
||||
return
|
||||
|
||||
tpl = round_obj.template
|
||||
attempt = await get_attempt(db2, round_id, db_user.id)
|
||||
if attempt:
|
||||
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), show_alert=True)
|
||||
return
|
||||
|
||||
secret_idx = round_obj.payload.get("secret_idx")
|
||||
correct_flag = ""
|
||||
if tpl.slug == GAME_SERVER:
|
||||
flags = round_obj.payload.get("flags") or []
|
||||
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
|
||||
|
||||
is_winner = False
|
||||
if tpl.slug == GAME_SERVER:
|
||||
is_winner = pick == correct_flag
|
||||
elif tpl.slug == GAME_QUEST:
|
||||
# Format: quest_{idx}
|
||||
try:
|
||||
if pick.startswith("quest_"):
|
||||
idx = int(pick.split("_")[1])
|
||||
is_winner = secret_idx is not None and idx == secret_idx
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
elif tpl.slug == GAME_LOCKS:
|
||||
# Format: locks_{idx}
|
||||
try:
|
||||
if pick.startswith("locks_"):
|
||||
idx = int(pick.split("_")[1])
|
||||
is_winner = secret_idx is not None and idx == secret_idx
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
is_winner = pick == "blitz"
|
||||
else:
|
||||
is_winner = False
|
||||
|
||||
# Check if max winners already reached
|
||||
if is_winner and round_obj.winners_count >= round_obj.max_winners:
|
||||
is_winner = False # Too late, max winners already reached
|
||||
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db2, round_obj)
|
||||
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
|
||||
await callback.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), show_alert=True)
|
||||
else:
|
||||
responses = {
|
||||
GAME_QUEST: ["Пусто", "Ложный сервер", "Найди другой узел"],
|
||||
GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"],
|
||||
GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"],
|
||||
}.get(tpl.slug, ["Неудача"])
|
||||
await callback.answer(random.choice(responses), show_alert=True)
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
round_id = data.get("contest_round_id")
|
||||
if not round_id:
|
||||
# Re-check subscription
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_NOT_ELIGIBLE", "Игра недоступна без активной подписки."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
|
||||
if not round_obj:
|
||||
await message.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Process attempt using service
|
||||
result = await _attempt_service.process_button_attempt(
|
||||
db=db2,
|
||||
round_obj=round_obj,
|
||||
user_id=db_user.id,
|
||||
pick=pick,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
await callback.answer(result.message, show_alert=True)
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
"""Handle text answer in contest games."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
round_id = data.get("contest_round_id")
|
||||
if not round_id:
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
|
||||
if not round_obj:
|
||||
await message.answer(
|
||||
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
attempt = await get_attempt(db2, round_obj.id, db_user.id)
|
||||
if attempt:
|
||||
await message.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
return
|
||||
# Process attempt using service
|
||||
text_answer = (message.text or "").strip()
|
||||
result = await _attempt_service.process_text_attempt(
|
||||
db=db2,
|
||||
round_obj=round_obj,
|
||||
user_id=db_user.id,
|
||||
text_answer=text_answer,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
answer = (message.text or "").strip().upper()
|
||||
tpl = round_obj.template
|
||||
correct = (round_obj.payload.get("answer") or "").upper()
|
||||
await message.answer(
|
||||
result.message,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
|
||||
is_winner = correct and answer == correct
|
||||
|
||||
# Check if max winners already reached
|
||||
if is_winner and round_obj.winners_count >= round_obj.max_winners:
|
||||
is_winner = False # Too late, max winners already reached
|
||||
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db2, round_obj)
|
||||
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
|
||||
await message.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), reply_markup=get_back_keyboard(db_user.language))
|
||||
else:
|
||||
await message.answer(texts.t("CONTEST_LOSE", "Не верно, попробуй снова в следующем раунде."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
"""Register contest handlers."""
|
||||
dp.callback_query.register(show_contests_menu, F.data == "contests_menu")
|
||||
dp.callback_query.register(play_contest, F.data.startswith("contest_play_"))
|
||||
dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_"))
|
||||
|
||||
+127
-41
@@ -1238,39 +1238,68 @@ async def handle_activate_button(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""
|
||||
Умная кнопка активации — система сама решает что делать:
|
||||
- Если подписка активна — ничего не делать
|
||||
- Если подписка истекла — продлить с теми же параметрами
|
||||
- Если подписки нет — создать новую с дефолтными параметрами
|
||||
Выбирает максимальный период, который можно оплатить из баланса.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Получить подписку пользователя
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.subscription_renewal_service import SubscriptionRenewalService
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
|
||||
# Если подписка активна — ничего не делаем
|
||||
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
|
||||
await callback.answer(
|
||||
texts.t("SUBSCRIPTION_ALREADY_ACTIVE", "✅ Подписка уже активна!"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Параметры из подписки или дефолтные
|
||||
device_limit = subscription.device_limit if subscription else settings.DEFAULT_DEVICE_LIMIT
|
||||
traffic_limit_gb = subscription.traffic_limit_gb if subscription else 0
|
||||
connected_squads = subscription.connected_squads if subscription else []
|
||||
|
||||
# Получить IDs серверов из UUIDs
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
|
||||
# Определяем параметры подписки
|
||||
if subscription:
|
||||
# Есть подписка (возможно истекшая) — берём её параметры
|
||||
device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||||
connected_squads = subscription.connected_squads or []
|
||||
else:
|
||||
# Нет подписки — дефолтные параметры
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
traffic_limit_gb = 0
|
||||
connected_squads = []
|
||||
|
||||
# Если серверы не выбраны — берём бесплатные по умолчанию
|
||||
if not connected_squads:
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=db_user.promo_group_id)
|
||||
connected_squads = [
|
||||
s.squad_uuid for s in available_servers
|
||||
if s.is_available and s.price_kopeks == 0
|
||||
]
|
||||
# Если бесплатных нет — берём первый доступный
|
||||
if not connected_squads and available_servers:
|
||||
connected_squads = [available_servers[0].squad_uuid]
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||||
|
||||
|
||||
balance = db_user.balance_kopeks
|
||||
available_periods = [int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS]
|
||||
|
||||
available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# Найти максимальный период <= баланса
|
||||
best_period = None
|
||||
best_price = 0
|
||||
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# Найти максимальный период, цена которого <= баланса
|
||||
for period in sorted(available_periods, reverse=True):
|
||||
|
||||
for period in available_periods:
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period,
|
||||
traffic_limit_gb,
|
||||
@@ -1283,31 +1312,88 @@ async def handle_activate_button(
|
||||
best_period = period
|
||||
best_price = price
|
||||
break
|
||||
|
||||
if best_period:
|
||||
# Создать новую подписку
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
new_subscription = await create_paid_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
best_period,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=True
|
||||
|
||||
if not best_period:
|
||||
# Показать сколько не хватает для минимального периода
|
||||
min_period = min(available_periods) if available_periods else 30
|
||||
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
)
|
||||
|
||||
# Списать деньги
|
||||
db_user.balance_kopeks -= best_price
|
||||
await db.commit()
|
||||
|
||||
missing = min_price - balance
|
||||
await callback.answer(
|
||||
texts.t("ACTIVATION_SUCCESS", f"✅ Подписка активирована на {best_period} дней за {best_price//100} руб!"),
|
||||
texts.t(
|
||||
"INSUFFICIENT_FUNDS_DETAILED",
|
||||
f"❌ Недостаточно средств. Не хватает {missing // 100} ₽"
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
try:
|
||||
if subscription:
|
||||
# Продление существующей подписки
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
pricing = await renewal_service.calculate_pricing(
|
||||
db, db_user, subscription, best_period
|
||||
)
|
||||
|
||||
result = await renewal_service.finalize(
|
||||
db, db_user, subscription,
|
||||
pricing,
|
||||
description=f"Автоматическое продление на {best_period} дней",
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ACTIVATION_SUCCESS",
|
||||
f"✅ Подписка продлена на {best_period} дней за {best_price // 100} ₽!"
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
else:
|
||||
# Создание новой подписки
|
||||
new_subscription = await create_paid_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
best_period,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=True
|
||||
)
|
||||
|
||||
# Списать баланс правильно
|
||||
await subtract_user_balance(
|
||||
db, db_user, best_price,
|
||||
f"Активация подписки на {best_period} дней"
|
||||
)
|
||||
|
||||
# Создать пользователя в RemnaWave
|
||||
await subscription_service.create_remnawave_user(db, new_subscription)
|
||||
|
||||
# Создать транзакцию
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=best_price,
|
||||
description=f"Активация подписки на {best_period} дней",
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ACTIVATION_SUCCESS",
|
||||
f"✅ Подписка активирована на {best_period} дней за {best_price // 100} ₽!"
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка автоматической активации для {db_user.telegram_id}: {e}")
|
||||
await callback.answer(
|
||||
texts.t("INSUFFICIENT_FUNDS", "❌ Недостаточно средств для активации подписки"),
|
||||
texts.t("ACTIVATION_ERROR", "❌ Ошибка активации. Попробуйте позже."),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -481,9 +481,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "referral_analytics"
|
||||
)
|
||||
|
||||
async def handle_referral_list_page(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_detailed_referral_list(callback, db_user, db, page)
|
||||
|
||||
dp.callback_query.register(
|
||||
lambda callback, db_user, db: show_detailed_referral_list(
|
||||
callback, db_user, db, int(callback.data.split('_')[-1])
|
||||
),
|
||||
handle_referral_list_page,
|
||||
F.data.startswith("referral_list_page_")
|
||||
)
|
||||
|
||||
@@ -120,6 +120,14 @@ from .traffic import (
|
||||
select_traffic,
|
||||
)
|
||||
|
||||
from .modem import (
|
||||
handle_modem_menu,
|
||||
handle_modem_enable,
|
||||
handle_modem_confirm,
|
||||
handle_modem_disable,
|
||||
register_modem_handlers,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'activate_trial',
|
||||
'add_traffic',
|
||||
@@ -185,6 +193,11 @@ __all__ = [
|
||||
'handle_subscription_config_back',
|
||||
'handle_subscription_settings',
|
||||
'handle_switch_traffic',
|
||||
'handle_modem_menu',
|
||||
'handle_modem_enable',
|
||||
'handle_modem_confirm',
|
||||
'handle_modem_disable',
|
||||
'register_modem_handlers',
|
||||
'load_app_config',
|
||||
'refresh_traffic_config',
|
||||
'register_handlers',
|
||||
|
||||
@@ -79,7 +79,12 @@ from app.utils.promo_offer import (
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
from .countries import _get_available_countries, _should_show_countries_management
|
||||
from .countries import (
|
||||
_build_countries_selection_text,
|
||||
_get_available_countries,
|
||||
_get_preselected_free_countries,
|
||||
_should_show_countries_management,
|
||||
)
|
||||
from .pricing import _build_subscription_period_prompt
|
||||
|
||||
async def handle_autopay_menu(
|
||||
@@ -260,9 +265,18 @@ async def _show_previous_configuration_step(
|
||||
data = await state.get_data()
|
||||
selected_countries = data.get('countries', [])
|
||||
|
||||
# Если страны не выбраны — автоматически предвыбираем бесплатные
|
||||
if not selected_countries:
|
||||
selected_countries = _get_preselected_free_countries(countries)
|
||||
data['countries'] = selected_countries
|
||||
await state.set_data(data)
|
||||
|
||||
# Формируем текст с описаниями сквадов
|
||||
selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
selection_text,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
return
|
||||
|
||||
@@ -390,9 +390,15 @@ def get_traffic_switch_keyboard(
|
||||
language: str = "ru",
|
||||
subscription_end_date: datetime = None,
|
||||
discount_percent: int = 0,
|
||||
base_traffic_gb: int = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
# Если базовый трафик не передан, используем текущий
|
||||
# (для обратной совместимости и случаев без докупленного трафика)
|
||||
if base_traffic_gb is None:
|
||||
base_traffic_gb = current_traffic_gb
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ""
|
||||
if subscription_end_date:
|
||||
@@ -403,7 +409,8 @@ def get_traffic_switch_keyboard(
|
||||
packages = settings.get_traffic_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
||||
|
||||
current_price_per_month = settings.get_traffic_price(current_traffic_gb)
|
||||
# Используем базовый трафик для определения цены текущего пакета
|
||||
current_price_per_month = settings.get_traffic_price(base_traffic_gb)
|
||||
discounted_current_per_month, _ = apply_percentage_discount(
|
||||
current_price_per_month,
|
||||
discount_percent,
|
||||
@@ -422,7 +429,8 @@ def get_traffic_switch_keyboard(
|
||||
price_diff_per_month = discounted_price_per_month - discounted_current_per_month
|
||||
total_price_diff = price_diff_per_month * months_multiplier
|
||||
|
||||
if gb == current_traffic_gb:
|
||||
# Сравниваем с базовым трафиком (без докупленного)
|
||||
if gb == base_traffic_gb:
|
||||
emoji = "✅"
|
||||
action_text = " (текущий)"
|
||||
price_text = ""
|
||||
|
||||
@@ -646,7 +646,8 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
|
||||
"name": server.display_name,
|
||||
"price_kopeks": server.price_kopeks,
|
||||
"country_code": server.country_code,
|
||||
"is_available": server.is_available and not server.is_full
|
||||
"is_available": server.is_available and not server.is_full,
|
||||
"description": server.description or ""
|
||||
})
|
||||
|
||||
if not countries:
|
||||
@@ -675,7 +676,8 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
|
||||
"uuid": squad["uuid"],
|
||||
"name": squad_name,
|
||||
"price_kopeks": 0,
|
||||
"is_available": True
|
||||
"is_available": True,
|
||||
"description": ""
|
||||
})
|
||||
|
||||
await cache.set(cache_key_value, countries, 300)
|
||||
@@ -684,7 +686,7 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка стран: {e}")
|
||||
fallback_countries = [
|
||||
{"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True},
|
||||
{"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True, "description": ""},
|
||||
]
|
||||
|
||||
await cache.set(cache_key_value, fallback_countries, 60)
|
||||
@@ -694,6 +696,36 @@ async def _get_countries_info(squad_uuids):
|
||||
countries = await _get_available_countries()
|
||||
return [c for c in countries if c['uuid'] in squad_uuids]
|
||||
|
||||
|
||||
def _get_preselected_free_countries(countries: List[dict]) -> List[str]:
|
||||
"""Получить UUID бесплатных серверов для автоматического предвыбора."""
|
||||
return [
|
||||
c['uuid'] for c in countries
|
||||
if c.get('is_available', True) and c.get('price_kopeks', 0) == 0
|
||||
]
|
||||
|
||||
|
||||
def _build_countries_selection_text(countries: List[dict], base_text: str) -> str:
|
||||
"""
|
||||
Формирует текст выбора серверов с описаниями.
|
||||
|
||||
Если у серверов есть description — добавляет их под базовым текстом.
|
||||
"""
|
||||
descriptions = []
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
desc = country.get('description', '').strip()
|
||||
if desc:
|
||||
name = country.get('name', '')
|
||||
descriptions.append(f"<b>{name}</b>\n{desc}")
|
||||
|
||||
if not descriptions:
|
||||
return base_text
|
||||
|
||||
return f"{base_text}\n\n" + "\n\n".join(descriptions)
|
||||
|
||||
|
||||
async def handle_add_country_to_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
|
||||
@@ -355,6 +355,28 @@ async def confirm_change_devices(
|
||||
).format(count=new_devices_count)
|
||||
cost_text = texts.t("DEVICE_CHANGE_NO_REFUND", "Возврат средств не производится")
|
||||
|
||||
# Проверяем количество подключённых устройств для предупреждения
|
||||
devices_warning = ""
|
||||
if new_devices_count < current_devices and db_user.remnawave_uuid:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
|
||||
if response and 'response' in response:
|
||||
connected_count = response['response'].get('total', 0)
|
||||
if connected_count > new_devices_count:
|
||||
devices_warning = texts.t(
|
||||
"DEVICE_CHANGE_RESET_WARNING",
|
||||
(
|
||||
"\n⚠️ <b>Внимание!</b>\n"
|
||||
"У вас подключено {connected} устройств.\n"
|
||||
"При уменьшении лимита до {new} все устройства будут сброшены.\n"
|
||||
"Вам нужно будет заново подключить нужные устройства.\n"
|
||||
),
|
||||
).format(connected=connected_count, new=new_devices_count)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки устройств: {e}")
|
||||
|
||||
confirm_text = texts.t(
|
||||
"DEVICE_CHANGE_CONFIRMATION",
|
||||
(
|
||||
@@ -372,6 +394,9 @@ async def confirm_change_devices(
|
||||
cost=cost_text,
|
||||
)
|
||||
|
||||
if devices_warning:
|
||||
confirm_text += devices_warning
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text,
|
||||
reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language),
|
||||
@@ -431,6 +456,28 @@ async def execute_change_devices(
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# При уменьшении лимита - сбросить лишние устройства
|
||||
devices_reset_count = 0
|
||||
if new_devices_count < current_devices and db_user.remnawave_uuid:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
|
||||
if response and 'response' in response:
|
||||
devices_list = response['response'].get('devices', [])
|
||||
connected_count = len(devices_list)
|
||||
|
||||
# Если подключённых устройств больше чем новый лимит - сбросить все
|
||||
if connected_count > new_devices_count:
|
||||
logger.info(
|
||||
f"🔧 Сброс устройств при уменьшении лимита: "
|
||||
f"подключено {connected_count}, новый лимит {new_devices_count}"
|
||||
)
|
||||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
devices_reset_count = connected_count
|
||||
except Exception as reset_error:
|
||||
logger.error(f"Ошибка сброса устройств при уменьшении лимита: {reset_error}")
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -466,6 +513,12 @@ async def execute_change_devices(
|
||||
"DEVICE_CHANGE_RESULT_LINE",
|
||||
"📱 Было: {old} → Стало: {new}\n",
|
||||
).format(old=current_devices, new=new_devices_count)
|
||||
if devices_reset_count > 0:
|
||||
success_text += texts.t(
|
||||
"DEVICE_CHANGE_DEVICES_RESET",
|
||||
"\n🔄 Сброшено устройств: {count}\n"
|
||||
"💡 Подключите заново нужные устройства (до {limit} шт.)\n\n",
|
||||
).format(count=devices_reset_count, limit=new_devices_count)
|
||||
success_text += texts.t(
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO",
|
||||
"ℹ️ Возврат средств не производится",
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
Хендлеры для управления модемом в подписке.
|
||||
|
||||
Модем - это дополнительное устройство, которое можно подключить к подписке
|
||||
за отдельную плату. При подключении увеличивается лимит устройств.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard, get_insufficient_balance_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.modem_service import (
|
||||
get_modem_service,
|
||||
ModemError,
|
||||
MODEM_WARNING_DAYS_CRITICAL,
|
||||
MODEM_WARNING_DAYS_INFO,
|
||||
)
|
||||
from app.utils.decorators import error_handler, modem_available
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_modem_keyboard(language: str, modem_enabled: bool):
|
||||
"""Клавиатура управления модемом."""
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if modem_enabled:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("MODEM_DISABLE_BUTTON", "Отключить модем"),
|
||||
callback_data="modem_disable"
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("MODEM_ENABLE_BUTTON", "Подключить модем"),
|
||||
callback_data="modem_enable"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="subscription_settings"
|
||||
)
|
||||
])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_modem_confirm_keyboard(language: str):
|
||||
"""Клавиатура подтверждения подключения модема."""
|
||||
texts = get_texts(language)
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("MODEM_CONFIRM_BUTTON", "Подтвердить подключение"),
|
||||
callback_data="modem_confirm"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.CANCEL,
|
||||
callback_data="subscription_modem"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available()
|
||||
async def handle_modem_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Показывает меню управления модемом."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
modem_enabled = service.get_modem_enabled(subscription)
|
||||
modem_price = settings.get_modem_price_per_month()
|
||||
|
||||
if modem_enabled:
|
||||
status_text = texts.t("MODEM_STATUS_ENABLED", "Подключен")
|
||||
info_text = texts.t(
|
||||
"MODEM_INFO_ENABLED",
|
||||
(
|
||||
"<b>Модем</b>\n\n"
|
||||
"Статус: {status}\n\n"
|
||||
"Модем подключен к вашей подписке.\n"
|
||||
"Ежемесячная плата: {price}\n\n"
|
||||
"При отключении модема возврат средств не производится."
|
||||
),
|
||||
).format(
|
||||
status=status_text,
|
||||
price=texts.format_price(modem_price),
|
||||
)
|
||||
else:
|
||||
status_text = texts.t("MODEM_STATUS_DISABLED", "Не подключен")
|
||||
info_text = texts.t(
|
||||
"MODEM_INFO_DISABLED",
|
||||
(
|
||||
"<b>Модем</b>\n\n"
|
||||
"Статус: {status}\n\n"
|
||||
"Подключите модем к вашей подписке.\n"
|
||||
"Ежемесячная плата: {price}\n\n"
|
||||
"При подключении модема будет добавлено дополнительное устройство."
|
||||
),
|
||||
).format(
|
||||
status=status_text,
|
||||
price=texts.format_price(modem_price),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
info_text,
|
||||
reply_markup=get_modem_keyboard(db_user.language, modem_enabled),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_enable(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Обработчик подключения модема - показывает информацию о цене."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
price_info = service.calculate_price(subscription)
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
|
||||
has_funds, missing_kopeks = service.check_balance(db_user, price_info.final_price)
|
||||
|
||||
if not has_funds:
|
||||
if price_info.has_discount:
|
||||
required_text = (
|
||||
f"{texts.format_price(price_info.final_price)} "
|
||||
f"(за {price_info.charged_months} мес, скидка {price_info.discount_percent}%)"
|
||||
)
|
||||
else:
|
||||
required_text = (
|
||||
f"{texts.format_price(price_info.final_price)} "
|
||||
f"(за {price_info.charged_months} мес)"
|
||||
)
|
||||
|
||||
message_text = texts.t(
|
||||
"MODEM_INSUFFICIENT_FUNDS",
|
||||
(
|
||||
"<b>Недостаточно средств</b>\n\n"
|
||||
"Стоимость подключения модема: {required}\n"
|
||||
"На балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\n"
|
||||
"Выберите способ пополнения."
|
||||
),
|
||||
).format(
|
||||
required=required_text,
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
warning_level = service.get_period_warning_level(price_info.remaining_days)
|
||||
|
||||
if warning_level == "critical":
|
||||
warning_text = texts.t(
|
||||
"MODEM_SHORT_PERIOD_WARNING",
|
||||
"\n<b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\n"
|
||||
"После продления подписки модем нужно будет оплатить заново!"
|
||||
).format(days=price_info.remaining_days)
|
||||
elif warning_level == "info":
|
||||
warning_text = texts.t(
|
||||
"MODEM_PERIOD_NOTE",
|
||||
"\nДо окончания подписки: <b>{days} дн.</b>\n"
|
||||
"После продления модем нужно будет оплатить заново."
|
||||
).format(days=price_info.remaining_days)
|
||||
else:
|
||||
warning_text = ""
|
||||
|
||||
if price_info.has_discount:
|
||||
price_text = texts.t(
|
||||
"MODEM_PRICE_WITH_DISCOUNT",
|
||||
"Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n"
|
||||
"Скидка {discount}%: -{discount_amount}"
|
||||
).format(
|
||||
base_price=texts.format_price(price_info.base_price),
|
||||
final_price=texts.format_price(price_info.final_price),
|
||||
months=price_info.charged_months,
|
||||
discount=price_info.discount_percent,
|
||||
discount_amount=texts.format_price(price_info.discount_amount),
|
||||
)
|
||||
else:
|
||||
price_text = texts.t(
|
||||
"MODEM_PRICE_NO_DISCOUNT",
|
||||
"Стоимость: {price} (за {months} мес)"
|
||||
).format(
|
||||
price=texts.format_price(price_info.final_price),
|
||||
months=price_info.charged_months,
|
||||
)
|
||||
|
||||
confirm_text = texts.t(
|
||||
"MODEM_CONFIRM_ENABLE_BASE",
|
||||
(
|
||||
"<b>Подтверждение подключения модема</b>\n\n"
|
||||
"{price_text}\n\n"
|
||||
"При подключении модема:\n"
|
||||
"К подписке добавится дополнительное устройство\n"
|
||||
"Ежемесячная плата увеличится на {monthly_price}\n\n"
|
||||
"Подтвердить подключение?"
|
||||
),
|
||||
).format(
|
||||
price_text=price_text,
|
||||
monthly_price=texts.format_price(modem_price_per_month),
|
||||
)
|
||||
|
||||
end_date_str = price_info.end_date.strftime("%d.%m.%Y")
|
||||
period_info = texts.t(
|
||||
"MODEM_PERIOD_INFO",
|
||||
"\nМодем действует до: <b>{end_date}</b> ({days} дн.)"
|
||||
).format(end_date=end_date_str, days=price_info.remaining_days)
|
||||
|
||||
confirm_text += period_info + warning_text
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text,
|
||||
reply_markup=get_modem_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Подтверждение и активация модема."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
result = await service.enable_modem(db, db_user, subscription)
|
||||
|
||||
if not result.success:
|
||||
error_messages = {
|
||||
ModemError.INSUFFICIENT_FUNDS: texts.t(
|
||||
"MODEM_INSUFFICIENT_FUNDS_SHORT",
|
||||
"Недостаточно средств на балансе"
|
||||
),
|
||||
ModemError.CHARGE_ERROR: texts.t(
|
||||
"PAYMENT_CHARGE_ERROR",
|
||||
"Ошибка списания средств"
|
||||
),
|
||||
ModemError.UPDATE_ERROR: texts.ERROR,
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result.error, texts.ERROR)
|
||||
|
||||
if result.error == ModemError.INSUFFICIENT_FUNDS:
|
||||
await callback.message.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language, "modem_enable"),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.answer(error_text, show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "modem", False, True, result.charged_amount
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о подключении модема: {e}")
|
||||
|
||||
success_text = texts.t(
|
||||
"MODEM_ENABLED_SUCCESS",
|
||||
(
|
||||
"<b>Модем успешно подключен!</b>\n\n"
|
||||
"Модем активирован\n"
|
||||
"Добавлено устройство для модема\n"
|
||||
),
|
||||
)
|
||||
if result.charged_amount > 0:
|
||||
success_text += texts.t(
|
||||
"MODEM_CHARGED",
|
||||
"Списано: {amount}",
|
||||
).format(amount=texts.format_price(result.charged_amount))
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language, "subscription_settings"),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_disable=True)
|
||||
async def handle_modem_disable(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Отключение модема."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
result = await service.disable_modem(db, db_user, subscription)
|
||||
|
||||
if not result.success:
|
||||
await callback.answer(texts.ERROR, show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "modem", True, False, 0
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об отключении модема: {e}")
|
||||
|
||||
success_text = texts.t(
|
||||
"MODEM_DISABLED_SUCCESS",
|
||||
(
|
||||
"<b>Модем отключен</b>\n\n"
|
||||
"Модем деактивирован\n"
|
||||
"Возврат средств не производится"
|
||||
),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language, "subscription_settings"),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_modem_handlers(dp: Dispatcher):
|
||||
"""Регистрация обработчиков модема."""
|
||||
dp.callback_query.register(
|
||||
handle_modem_menu,
|
||||
F.data == "subscription_modem"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_modem_enable,
|
||||
F.data == "modem_enable"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_modem_confirm,
|
||||
F.data == "modem_confirm"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_modem_disable,
|
||||
F.data == "modem_disable"
|
||||
)
|
||||
@@ -17,6 +17,7 @@ from app.database.crud.discount_offer import (
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
from app.database.crud.subscription import (
|
||||
create_trial_subscription,
|
||||
create_pending_trial_subscription,
|
||||
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
|
||||
update_subscription_autopay
|
||||
)
|
||||
@@ -49,6 +50,7 @@ from app.keyboards.inline import (
|
||||
)
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import error_handler
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
@@ -138,7 +140,9 @@ from .autopay import (
|
||||
toggle_autopay,
|
||||
)
|
||||
from .countries import (
|
||||
_build_countries_selection_text,
|
||||
_get_available_countries,
|
||||
_get_preselected_free_countries,
|
||||
_should_show_countries_management,
|
||||
apply_countries_changes,
|
||||
countries_continue,
|
||||
@@ -346,6 +350,15 @@ async def show_subscription_info(
|
||||
"",
|
||||
)
|
||||
|
||||
# Формируем отображение лимита устройств с учётом модема
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
if modem_enabled and settings.is_modem_enabled():
|
||||
# Показываем лимит без модема + модем
|
||||
visible_device_limit = (subscription.device_limit or 1) - 1
|
||||
device_limit_display = f"{visible_device_limit} + модем"
|
||||
else:
|
||||
device_limit_display = str(subscription.device_limit)
|
||||
|
||||
message = message_template.format(
|
||||
full_name=db_user.full_name,
|
||||
balance=settings.format_price(db_user.balance_kopeks),
|
||||
@@ -358,7 +371,7 @@ async def show_subscription_info(
|
||||
traffic=traffic_used_display,
|
||||
servers=servers_display,
|
||||
devices_used=devices_used_str,
|
||||
device_limit=subscription.device_limit,
|
||||
device_limit=device_limit_display,
|
||||
)
|
||||
|
||||
if show_devices and devices_list:
|
||||
@@ -489,12 +502,86 @@ async def show_trial_offer(
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
def _get_trial_payment_keyboard(language: str, can_pay_from_balance: bool = False) -> types.InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру с методами оплаты для платного триала."""
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
# Кнопка оплаты с баланса (если хватает средств)
|
||||
if can_pay_from_balance:
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="✅ Оплатить с баланса",
|
||||
callback_data="trial_pay_with_balance"
|
||||
)])
|
||||
|
||||
# Добавляем доступные методы оплаты
|
||||
if settings.TELEGRAM_STARS_ENABLED:
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="⭐ Telegram Stars",
|
||||
callback_data="trial_payment_stars"
|
||||
)])
|
||||
|
||||
if settings.is_yookassa_enabled():
|
||||
yookassa_methods = []
|
||||
if settings.YOOKASSA_SBP_ENABLED:
|
||||
yookassa_methods.append(types.InlineKeyboardButton(
|
||||
text="🏦 YooKassa (СБП)",
|
||||
callback_data="trial_payment_yookassa_sbp"
|
||||
))
|
||||
yookassa_methods.append(types.InlineKeyboardButton(
|
||||
text="💳 YooKassa (Карта)",
|
||||
callback_data="trial_payment_yookassa"
|
||||
))
|
||||
if yookassa_methods:
|
||||
keyboard.append(yookassa_methods)
|
||||
|
||||
if settings.is_cryptobot_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="🪙 CryptoBot",
|
||||
callback_data="trial_payment_cryptobot"
|
||||
)])
|
||||
|
||||
if settings.is_heleket_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="🪙 Heleket",
|
||||
callback_data="trial_payment_heleket"
|
||||
)])
|
||||
|
||||
if settings.is_mulenpay_enabled():
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=f"💳 {mulenpay_name}",
|
||||
callback_data="trial_payment_mulenpay"
|
||||
)])
|
||||
|
||||
if settings.is_pal24_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="💳 PayPalych",
|
||||
callback_data="trial_payment_pal24"
|
||||
)])
|
||||
|
||||
if settings.is_wata_enabled():
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text="💳 WATA",
|
||||
callback_data="trial_payment_wata"
|
||||
)])
|
||||
|
||||
# Кнопка назад
|
||||
keyboard.append([types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="menu_trial"
|
||||
)])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
async def activate_trial(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.trial_activation_service import get_trial_activation_charge_amount
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -506,29 +593,51 @@ async def activate_trial(
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
preview_trial_activation_charge(db_user)
|
||||
except TrialPaymentInsufficientFunds as error:
|
||||
required_label = settings.format_price(error.required_amount)
|
||||
balance_label = settings.format_price(error.balance_amount)
|
||||
missing_label = settings.format_price(error.missing_amount)
|
||||
message = texts.t(
|
||||
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS",
|
||||
"⚠️ Недостаточно средств для активации триала.\n"
|
||||
"Необходимо: {required}\nНа балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\nПополните баланс и попробуйте снова.",
|
||||
).format(required=required_label, balance=balance_label, missing=missing_label)
|
||||
# Проверяем, платный ли триал
|
||||
trial_price_kopeks = get_trial_activation_charge_amount()
|
||||
|
||||
if trial_price_kopeks > 0:
|
||||
# Платный триал - показываем экран с выбором метода оплаты
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) or 0
|
||||
can_pay_from_balance = user_balance_kopeks >= trial_price_kopeks
|
||||
|
||||
traffic_label = "Безлимит" if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f"{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ"
|
||||
|
||||
message_lines = [
|
||||
texts.t("PAID_TRIAL_HEADER", "⚡ <b>Пробная подписка</b>"),
|
||||
"",
|
||||
f"📅 {texts.t('PERIOD', 'Период')}: {settings.TRIAL_DURATION_DAYS} {texts.t('DAYS', 'дней')}",
|
||||
f"📊 {texts.t('TRAFFIC', 'Трафик')}: {traffic_label}",
|
||||
f"📱 {texts.t('DEVICES', 'Устройства')}: {settings.TRIAL_DEVICE_LIMIT}",
|
||||
"",
|
||||
f"💰 {texts.t('PRICE', 'Стоимость')}: {settings.format_price(trial_price_kopeks)}",
|
||||
f"💳 {texts.t('YOUR_BALANCE', 'Ваш баланс')}: {settings.format_price(user_balance_kopeks)}",
|
||||
"",
|
||||
]
|
||||
|
||||
if can_pay_from_balance:
|
||||
message_lines.append(texts.t(
|
||||
"PAID_TRIAL_CAN_PAY_BALANCE",
|
||||
"Вы можете оплатить пробную подписку с баланса или выбрать другой способ оплаты."
|
||||
))
|
||||
else:
|
||||
message_lines.append(texts.t(
|
||||
"PAID_TRIAL_SELECT_PAYMENT",
|
||||
"Выберите подходящий способ оплаты:"
|
||||
))
|
||||
|
||||
message_text = "\n".join(message_lines)
|
||||
keyboard = _get_trial_payment_keyboard(db_user.language, can_pay_from_balance)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=error.required_amount,
|
||||
),
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Бесплатный триал - текущее поведение
|
||||
charged_amount = 0
|
||||
subscription: Optional[Subscription] = None
|
||||
remnawave_user = None
|
||||
@@ -1738,9 +1847,16 @@ async def select_period(
|
||||
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
# Автоматически предвыбираем бесплатные серверы
|
||||
preselected = _get_preselected_free_countries(countries)
|
||||
data['countries'] = preselected
|
||||
await state.set_data(data)
|
||||
# Формируем текст с описаниями сквадов
|
||||
selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
selection_text,
|
||||
reply_markup=get_countries_keyboard(countries, preselected, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
await callback.answer()
|
||||
@@ -2683,12 +2799,20 @@ async def handle_subscription_settings(
|
||||
"",
|
||||
)
|
||||
|
||||
# Формируем отображение лимита устройств с учётом модема
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
if modem_enabled and settings.is_modem_enabled():
|
||||
visible_device_limit = (subscription.device_limit or 1) - 1
|
||||
devices_limit_display = f"{visible_device_limit} + модем"
|
||||
else:
|
||||
devices_limit_display = str(subscription.device_limit)
|
||||
|
||||
settings_text = settings_template.format(
|
||||
countries_count=len(subscription.connected_squads),
|
||||
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
|
||||
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb),
|
||||
devices_used=devices_used,
|
||||
devices_limit=subscription.device_limit,
|
||||
devices_limit=devices_limit_display,
|
||||
)
|
||||
|
||||
show_countries = await _should_show_countries_management(db_user)
|
||||
@@ -2715,6 +2839,696 @@ async def clear_saved_cart(
|
||||
|
||||
await callback.answer("🗑️ Корзина очищена")
|
||||
|
||||
|
||||
# ============== ХЕНДЛЕРЫ ПЛАТНОГО ТРИАЛА ==============
|
||||
|
||||
@error_handler
|
||||
async def handle_trial_pay_with_balance(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Обрабатывает оплату триала с баланса."""
|
||||
from app.services.trial_activation_service import get_trial_activation_charge_amount
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверяем права на триал
|
||||
if db_user.subscription or db_user.has_had_paid_subscription:
|
||||
await callback.message.edit_text(
|
||||
texts.TRIAL_ALREADY_USED,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
trial_price_kopeks = get_trial_activation_charge_amount()
|
||||
if trial_price_kopeks <= 0:
|
||||
await callback.answer("❌ Ошибка: триал бесплатный", show_alert=True)
|
||||
return
|
||||
|
||||
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) or 0
|
||||
if user_balance_kopeks < trial_price_kopeks:
|
||||
await callback.answer(
|
||||
texts.t("INSUFFICIENT_BALANCE", "❌ Недостаточно средств на балансе"),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
# Списываем с баланса
|
||||
success = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
trial_price_kopeks,
|
||||
texts.t("TRIAL_PAYMENT_DESCRIPTION", "Оплата пробной подписки"),
|
||||
)
|
||||
|
||||
if not success:
|
||||
await callback.answer(
|
||||
texts.t("PAYMENT_FAILED", "❌ Не удалось списать средства"),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
# Создаем триальную подписку
|
||||
subscription: Optional[Subscription] = None
|
||||
remnawave_user = None
|
||||
|
||||
try:
|
||||
forced_devices = None
|
||||
if not settings.is_devices_selection_enabled():
|
||||
forced_devices = settings.get_disabled_mode_device_limit()
|
||||
|
||||
subscription = await create_trial_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
device_limit=forced_devices,
|
||||
)
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
remnawave_user = await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
)
|
||||
except RemnaWaveConfigurationError as error:
|
||||
logger.error("RemnaWave update skipped due to configuration error: %s", error)
|
||||
# Откатываем подписку и возвращаем деньги
|
||||
await rollback_trial_subscription_activation(db, subscription)
|
||||
from app.database.crud.user import add_user_balance
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
trial_price_kopeks,
|
||||
texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"),
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"TRIAL_PROVISIONING_FAILED",
|
||||
"Не удалось завершить активацию триала. Средства возвращены на баланс.",
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Failed to create RemnaWave user for trial subscription %s: %s",
|
||||
getattr(subscription, "id", "<unknown>"),
|
||||
error,
|
||||
)
|
||||
# Откатываем подписку и возвращаем деньги
|
||||
await rollback_trial_subscription_activation(db, subscription)
|
||||
from app.database.crud.user import add_user_balance
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
trial_price_kopeks,
|
||||
texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"),
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"TRIAL_PROVISIONING_FAILED",
|
||||
"Не удалось завершить активацию триала. Средства возвращены на баланс.",
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_trial_activation_notification(
|
||||
db,
|
||||
db_user,
|
||||
subscription,
|
||||
charged_amount_kopeks=trial_price_kopeks,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о триале: {e}")
|
||||
|
||||
# Показываем успешное сообщение с ссылкой
|
||||
subscription_link = get_display_subscription_link(subscription)
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
payment_note = "\n\n" + texts.t(
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE",
|
||||
"💳 С вашего баланса списано {amount}.",
|
||||
).format(amount=settings.format_price(trial_price_kopeks))
|
||||
|
||||
if remnawave_user and subscription_link:
|
||||
if settings.is_happ_cryptolink_mode():
|
||||
trial_success_text = (
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_HAPP_LINK_PROMPT",
|
||||
"🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
)
|
||||
elif hide_subscription_link:
|
||||
trial_success_text = (
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
|
||||
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
|
||||
)
|
||||
+ "\n\n"
|
||||
+ texts.t(
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
|
||||
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
)
|
||||
)
|
||||
else:
|
||||
subscription_import_link = texts.t(
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION",
|
||||
"🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n<code>{subscription_url}</code>",
|
||||
).format(subscription_url=subscription_link)
|
||||
|
||||
trial_success_text = (
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n"
|
||||
f"{subscription_import_link}\n\n"
|
||||
f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
|
||||
)
|
||||
|
||||
trial_success_text += payment_note
|
||||
|
||||
connect_mode = settings.CONNECT_BUTTON_MODE
|
||||
connect_keyboard = _build_trial_success_keyboard(texts, subscription_link, connect_mode)
|
||||
|
||||
await callback.message.edit_text(
|
||||
trial_success_text,
|
||||
reply_markup=connect_keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
trial_success_text = (
|
||||
f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд."
|
||||
)
|
||||
trial_success_text += payment_note
|
||||
|
||||
await callback.message.edit_text(
|
||||
trial_success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Unexpected error during paid trial activation for user %s: %s",
|
||||
db_user.id,
|
||||
error,
|
||||
)
|
||||
# Пытаемся откатить и вернуть деньги
|
||||
if subscription:
|
||||
await rollback_trial_subscription_activation(db, subscription)
|
||||
from app.database.crud.user import add_user_balance
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
trial_price_kopeks,
|
||||
texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"),
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"TRIAL_ACTIVATION_ERROR",
|
||||
"❌ Произошла ошибка при активации триала. Средства возвращены на баланс.",
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _build_trial_success_keyboard(texts, subscription_link: str, connect_mode: str) -> InlineKeyboardMarkup:
|
||||
"""Создает клавиатуру успешной активации триала."""
|
||||
|
||||
if connect_mode == "miniapp_subscription":
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
web_app=types.WebAppInfo(url=subscription_link),
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
])
|
||||
elif connect_mode == "miniapp_custom":
|
||||
if not settings.MINIAPP_CUSTOM_URL:
|
||||
return get_back_keyboard(texts.language if hasattr(texts, 'language') else 'ru')
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
url=subscription_link,
|
||||
)
|
||||
]
|
||||
]
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
rows.append(happ_row)
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
]
|
||||
)
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
elif connect_mode == "happ_cryptolink":
|
||||
rows = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="open_subscription_link",
|
||||
)
|
||||
]
|
||||
]
|
||||
happ_row = get_happ_download_button_row(texts)
|
||||
if happ_row:
|
||||
rows.append(happ_row)
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
]
|
||||
)
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
else:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
|
||||
callback_data="subscription_connect",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
|
||||
callback_data="back_to_menu",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_trial_payment_method(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Обрабатывает выбор метода оплаты для платного триала."""
|
||||
from app.services.trial_activation_service import get_trial_activation_charge_amount
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверяем права на триал
|
||||
if db_user.subscription or db_user.has_had_paid_subscription:
|
||||
await callback.message.edit_text(
|
||||
texts.TRIAL_ALREADY_USED,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
trial_price_kopeks = get_trial_activation_charge_amount()
|
||||
if trial_price_kopeks <= 0:
|
||||
await callback.answer("❌ Ошибка: триал бесплатный", show_alert=True)
|
||||
return
|
||||
|
||||
# Определяем метод оплаты
|
||||
payment_method = callback.data.replace("trial_payment_", "")
|
||||
|
||||
try:
|
||||
payment_service = PaymentService(callback.bot)
|
||||
|
||||
# Получаем случайный сквад для триала
|
||||
from app.database.crud.server_squad import get_random_trial_squad_uuid
|
||||
trial_squad_uuid = await get_random_trial_squad_uuid(db)
|
||||
|
||||
# Создаем pending триальную подписку
|
||||
pending_subscription = await create_pending_trial_subscription(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
duration_days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.TRIAL_DEVICE_LIMIT,
|
||||
connected_squads=[trial_squad_uuid] if trial_squad_uuid else [],
|
||||
payment_method=f"trial_{payment_method}",
|
||||
total_price_kopeks=trial_price_kopeks,
|
||||
)
|
||||
|
||||
if not pending_subscription:
|
||||
await callback.answer("❌ Не удалось подготовить заказ. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
traffic_label = "Безлимит" if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f"{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ"
|
||||
|
||||
if payment_method == "stars":
|
||||
# Оплата через Telegram Stars
|
||||
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(trial_price_kopeks))
|
||||
|
||||
await callback.bot.send_invoice(
|
||||
chat_id=callback.from_user.id,
|
||||
title=texts.t("PAID_TRIAL_INVOICE_TITLE", "Пробная подписка на {days} дней").format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
),
|
||||
description=(
|
||||
f"{texts.t('PERIOD', 'Период')}: {settings.TRIAL_DURATION_DAYS} {texts.t('DAYS', 'дней')}\n"
|
||||
f"{texts.t('DEVICES', 'Устройства')}: {settings.TRIAL_DEVICE_LIMIT}\n"
|
||||
f"{texts.t('TRAFFIC', 'Трафик')}: {traffic_label}"
|
||||
),
|
||||
payload=f"trial_{pending_subscription.id}",
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=[types.LabeledPrice(
|
||||
label=texts.t("PAID_TRIAL_STARS_LABEL", "Пробная подписка"),
|
||||
amount=stars_count
|
||||
)],
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PAID_TRIAL_STARS_WAITING",
|
||||
"⭐ Для оплаты пробной подписки нажмите кнопку оплаты в сообщении выше.\n\n"
|
||||
"После успешной оплаты подписка будет активирована автоматически."
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
elif payment_method == "yookassa_sbp":
|
||||
# Оплата через YooKassa СБП
|
||||
payment_result = await payment_service.create_yookassa_sbp_payment(
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("confirmation_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
qr_url = payment_result.get("qr_code_url") or payment_result.get("confirmation_url")
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PAID_TRIAL_YOOKASSA_SBP",
|
||||
"🏦 <b>Оплата через СБП</b>\n\n"
|
||||
"Отсканируйте QR-код или перейдите по ссылке для оплаты.\n\n"
|
||||
"💰 Сумма: {amount}"
|
||||
).format(amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Оплатить", url=qr_url)],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
|
||||
]),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
elif payment_method == "yookassa":
|
||||
# Оплата через YooKassa карта
|
||||
payment_result = await payment_service.create_yookassa_payment(
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("confirmation_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PAID_TRIAL_YOOKASSA_CARD",
|
||||
"💳 <b>Оплата картой</b>\n\n"
|
||||
"Нажмите кнопку ниже для перехода к оплате.\n\n"
|
||||
"💰 Сумма: {amount}"
|
||||
).format(amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["confirmation_url"])],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
|
||||
]),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
elif payment_method == "cryptobot":
|
||||
# Оплата через CryptoBot
|
||||
payment_result = await payment_service.create_cryptobot_payment(
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("pay_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PAID_TRIAL_CRYPTOBOT",
|
||||
"🪙 <b>Оплата криптовалютой</b>\n\n"
|
||||
"Нажмите кнопку ниже для перехода к оплате.\n\n"
|
||||
"💰 Сумма: {amount}"
|
||||
).format(amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["pay_url"])],
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
|
||||
callback_data=f"check_trial_cryptobot_{pending_subscription.id}"
|
||||
)],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
|
||||
]),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
elif payment_method == "heleket":
|
||||
# Оплата через Heleket
|
||||
payment_result = await payment_service.create_heleket_payment(
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("pay_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PAID_TRIAL_HELEKET",
|
||||
"🪙 <b>Оплата криптовалютой (Heleket)</b>\n\n"
|
||||
"Нажмите кнопку ниже для перехода к оплате.\n\n"
|
||||
"💰 Сумма: {amount}"
|
||||
).format(amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["pay_url"])],
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
|
||||
callback_data=f"check_trial_heleket_{pending_subscription.id}"
|
||||
)],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
|
||||
]),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
elif payment_method == "mulenpay":
|
||||
# Оплата через MulenPay
|
||||
payment_result = await payment_service.create_mulenpay_payment(
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("pay_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PAID_TRIAL_MULENPAY",
|
||||
"💳 <b>Оплата через {name}</b>\n\n"
|
||||
"Нажмите кнопку ниже для перехода к оплате.\n\n"
|
||||
"💰 Сумма: {amount}"
|
||||
).format(name=mulenpay_name, amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
|
||||
callback_data=f"check_trial_mulenpay_{pending_subscription.id}"
|
||||
)],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
|
||||
]),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
elif payment_method == "pal24":
|
||||
# Оплата через PAL24
|
||||
payment_result = await payment_service.create_pal24_payment(
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("pay_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PAID_TRIAL_PAL24",
|
||||
"💳 <b>Оплата через PayPalych</b>\n\n"
|
||||
"Нажмите кнопку ниже для перехода к оплате.\n\n"
|
||||
"💰 Сумма: {amount}"
|
||||
).format(amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
|
||||
callback_data=f"check_trial_pal24_{pending_subscription.id}"
|
||||
)],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
|
||||
]),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
elif payment_method == "wata":
|
||||
# Оплата через WATA
|
||||
payment_result = await payment_service.create_wata_payment(
|
||||
amount_kopeks=trial_price_kopeks,
|
||||
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
|
||||
days=settings.TRIAL_DURATION_DAYS
|
||||
),
|
||||
user_id=db_user.id,
|
||||
metadata={
|
||||
"type": "trial",
|
||||
"subscription_id": pending_subscription.id,
|
||||
"user_id": db_user.id,
|
||||
},
|
||||
)
|
||||
|
||||
if not payment_result or not payment_result.get("pay_url"):
|
||||
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PAID_TRIAL_WATA",
|
||||
"💳 <b>Оплата через WATA</b>\n\n"
|
||||
"Нажмите кнопку ниже для перехода к оплате.\n\n"
|
||||
"💰 Сумма: {amount}"
|
||||
).format(amount=settings.format_price(trial_price_kopeks)),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
|
||||
callback_data=f"check_trial_wata_{pending_subscription.id}"
|
||||
)],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
|
||||
]),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
else:
|
||||
await callback.answer(f"❌ Неизвестный метод оплаты: {payment_method}", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Error processing trial payment method {payment_method}: {error}")
|
||||
await callback.answer("❌ Произошла ошибка при создании платежа. Попробуйте позже.", show_alert=True)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
update_traffic_prices()
|
||||
|
||||
@@ -2733,6 +3547,17 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "trial_activate"
|
||||
)
|
||||
|
||||
# Хендлеры платного триала
|
||||
dp.callback_query.register(
|
||||
handle_trial_pay_with_balance,
|
||||
F.data == "trial_pay_with_balance"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_trial_payment_method,
|
||||
F.data.startswith("trial_payment_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_subscription_purchase,
|
||||
F.data.in_(["menu_buy", "subscription_upgrade", "subscription_purchase"])
|
||||
@@ -3001,6 +3826,10 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "device_connection_help"
|
||||
)
|
||||
|
||||
# Регистрируем обработчики модема
|
||||
from .modem import register_modem_handlers
|
||||
register_modem_handlers(dp)
|
||||
|
||||
# Регистрируем обработчик для простой покупки
|
||||
dp.callback_query.register(
|
||||
handle_simple_subscription_purchase,
|
||||
|
||||
@@ -79,7 +79,12 @@ from app.utils.promo_offer import (
|
||||
)
|
||||
|
||||
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
|
||||
from .countries import _get_available_countries, _should_show_countries_management
|
||||
from .countries import (
|
||||
_build_countries_selection_text,
|
||||
_get_available_countries,
|
||||
_get_preselected_free_countries,
|
||||
_should_show_countries_management,
|
||||
)
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
async def handle_add_traffic(
|
||||
@@ -91,6 +96,17 @@ async def handle_add_traffic(
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверяем, включена ли функция докупки трафика
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TRAFFIC_TOPUP_DISABLED",
|
||||
"⚠️ Функция докупки трафика отключена",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
@@ -146,6 +162,43 @@ async def handle_add_traffic(
|
||||
|
||||
await callback.answer()
|
||||
|
||||
def _calculate_traffic_reset_price(subscription) -> int:
|
||||
"""Рассчитывает цену сброса трафика в зависимости от настроек."""
|
||||
mode = settings.get_traffic_reset_price_mode()
|
||||
base_price = settings.get_traffic_reset_base_price()
|
||||
|
||||
# Если базовая цена не задана, используем цену периода 30 дней
|
||||
if base_price == 0:
|
||||
base_price = PERIOD_PRICES.get(30, 0)
|
||||
|
||||
if mode == "period":
|
||||
# Старое поведение: фиксированная цена = стоимость периода
|
||||
return base_price
|
||||
|
||||
elif mode == "traffic":
|
||||
# Цена = стоимость текущего пакета трафика
|
||||
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
return max(traffic_price, base_price)
|
||||
|
||||
elif mode == "traffic_with_purchased":
|
||||
# Цена = стоимость базового трафика + докупленного
|
||||
# Базовый трафик = текущий лимит - докупленный
|
||||
purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
|
||||
|
||||
# Получаем цену базового трафика
|
||||
base_traffic_price = settings.get_traffic_price(base_traffic_gb) if base_traffic_gb > 0 else 0
|
||||
|
||||
# Получаем цену докупленного трафика
|
||||
purchased_traffic_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0
|
||||
|
||||
total_price = base_traffic_price + purchased_traffic_price
|
||||
return max(total_price, base_price)
|
||||
|
||||
# Fallback на базовую цену
|
||||
return base_price
|
||||
|
||||
|
||||
async def handle_reset_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -168,19 +221,40 @@ async def handle_reset_traffic(
|
||||
await callback.answer("⌛ У вас безлимитный трафик", show_alert=True)
|
||||
return
|
||||
|
||||
reset_price = PERIOD_PRICES[30]
|
||||
reset_price = _calculate_traffic_reset_price(subscription)
|
||||
|
||||
if db_user.balance_kopeks < reset_price:
|
||||
await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
|
||||
return
|
||||
# Формируем информацию о расчете цены
|
||||
purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
price_info = ""
|
||||
if purchased_gb > 0 and settings.get_traffic_reset_price_mode() == "traffic_with_purchased":
|
||||
base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
|
||||
price_info = (
|
||||
f"\n\n💡 <i>Расчет цены:</i>\n"
|
||||
f"• Базовый трафик: {texts.format_traffic(base_traffic_gb)}\n"
|
||||
f"• Докупленный: {texts.format_traffic(purchased_gb)}"
|
||||
)
|
||||
|
||||
# Проверяем достаточно ли средств
|
||||
has_enough_balance = db_user.balance_kopeks >= reset_price
|
||||
missing_kopeks = max(0, reset_price - db_user.balance_kopeks)
|
||||
|
||||
# Формируем текст о балансе
|
||||
balance_info = f"\n\n💰 На балансе: {texts.format_price(db_user.balance_kopeks)}"
|
||||
if not has_enough_balance:
|
||||
balance_info += f"\n⚠️ Не хватает: {texts.format_price(missing_kopeks)}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🔄 <b>Сброс трафика</b>\n\n"
|
||||
f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n"
|
||||
f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n"
|
||||
f"Стоимость сброса: {texts.format_price(reset_price)}\n\n"
|
||||
f"Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n"
|
||||
"После сброса счетчик использованного трафика станет равным 0.",
|
||||
reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language)
|
||||
reply_markup=get_reset_traffic_confirm_keyboard(
|
||||
reset_price,
|
||||
db_user.language,
|
||||
has_enough_balance=has_enough_balance,
|
||||
missing_kopeks=missing_kopeks,
|
||||
)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
@@ -199,7 +273,7 @@ async def confirm_reset_traffic(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
reset_price = PERIOD_PRICES[30]
|
||||
reset_price = _calculate_traffic_reset_price(subscription)
|
||||
|
||||
if db_user.balance_kopeks < reset_price:
|
||||
missing_kopeks = reset_price - db_user.balance_kopeks
|
||||
@@ -348,9 +422,16 @@ async def select_traffic(
|
||||
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
# Автоматически предвыбираем бесплатные серверы
|
||||
preselected = _get_preselected_free_countries(countries)
|
||||
data['countries'] = preselected
|
||||
await state.set_data(data)
|
||||
# Формируем текст с описаниями сквадов
|
||||
selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
selection_text,
|
||||
reply_markup=get_countries_keyboard(countries, preselected, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
await callback.answer()
|
||||
@@ -388,7 +469,7 @@ async def add_traffic(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
base_price = settings.get_traffic_price(traffic_gb)
|
||||
base_price = settings.get_traffic_topup_price(traffic_gb)
|
||||
|
||||
if base_price == 0 and traffic_gb != 0:
|
||||
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
|
||||
@@ -458,8 +539,13 @@ async def add_traffic(
|
||||
|
||||
if traffic_gb == 0:
|
||||
subscription.traffic_limit_gb = 0
|
||||
# При переходе на безлимит сбрасываем докупленный трафик
|
||||
subscription.purchased_traffic_gb = 0
|
||||
else:
|
||||
await add_subscription_traffic(db, subscription, traffic_gb)
|
||||
# Записываем докупленный трафик для корректного расчета цены сброса
|
||||
current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
subscription.purchased_traffic_gb = current_purchased + traffic_gb
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
@@ -535,6 +621,10 @@ async def handle_switch_traffic(
|
||||
return
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
# Вычисляем базовый трафик (без докупленного) для корректного расчёта цен
|
||||
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
base_traffic = current_traffic - purchased_traffic
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
@@ -542,18 +632,25 @@ async def handle_switch_traffic(
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
# Показываем информацию о докупленном трафике, если он есть
|
||||
purchased_info = ""
|
||||
if purchased_traffic > 0:
|
||||
purchased_info = f"\n📦 Базовый пакет: {texts.format_traffic(base_traffic)}\n➕ Докуплено: {texts.format_traffic(purchased_traffic)}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🔄 <b>Переключение лимита трафика</b>\n\n"
|
||||
f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
|
||||
f"Текущий лимит: {texts.format_traffic(current_traffic)}{purchased_info}\n"
|
||||
f"Выберите новый лимит трафика:\n\n"
|
||||
f"💡 <b>Важно:</b>\n"
|
||||
f"• При увеличении - доплата за разницу\n"
|
||||
f"• При уменьшении - возврат средств не производится",
|
||||
f"• При уменьшении - возврат средств не производится\n"
|
||||
f"• Докупленный трафик будет сброшен",
|
||||
reply_markup=get_traffic_switch_keyboard(
|
||||
current_traffic,
|
||||
db_user.language,
|
||||
subscription.end_date,
|
||||
traffic_discount_percent,
|
||||
base_traffic_gb=base_traffic,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -571,11 +668,16 @@ async def confirm_switch_traffic(
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
|
||||
# Вычисляем базовый трафик (без докупленного) для корректного расчёта цены
|
||||
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
base_traffic = current_traffic - purchased_traffic
|
||||
|
||||
if new_traffic_gb == current_traffic:
|
||||
await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True)
|
||||
return
|
||||
|
||||
old_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
# Используем базовый трафик для определения текущей цены пакета
|
||||
old_price_per_month = settings.get_traffic_price(base_traffic)
|
||||
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
|
||||
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
@@ -692,6 +794,8 @@ async def execute_switch_traffic(
|
||||
)
|
||||
|
||||
subscription.traffic_limit_gb = new_traffic_gb
|
||||
# Сбрасываем докупленный трафик при переключении пакета
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
+55
-3
@@ -836,7 +836,7 @@ async def handle_ticket_reply(
|
||||
)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await message.answer(
|
||||
texts.t("TICKET_REPLY_SENT", "✅ Ваш ответ отправлен!"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -850,9 +850,13 @@ async def handle_ticket_reply(
|
||||
)]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
# Уведомить админов об ответе пользователя
|
||||
logger.info(f"Attempting to notify admins about ticket reply #{ticket_id}")
|
||||
await notify_admins_about_ticket_reply(ticket, reply_text, db)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding ticket reply: {e}")
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -1015,6 +1019,54 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
logger.error(f"Error notifying admins about new ticket: {e}")
|
||||
|
||||
|
||||
async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db: AsyncSession):
|
||||
"""Уведомить админов об ответе пользователя на тикет"""
|
||||
logger.info(f"notify_admins_about_ticket_reply called for ticket #{ticket.id}")
|
||||
try:
|
||||
from app.config import settings
|
||||
if not settings.is_admin_notifications_enabled():
|
||||
logger.info(f"Admin notifications disabled. Reply to ticket #{ticket.id}")
|
||||
return
|
||||
|
||||
title = (ticket.title or '').strip()
|
||||
if len(title) > 60:
|
||||
title = title[:57] + "..."
|
||||
|
||||
# Загрузим пользователя
|
||||
try:
|
||||
user = await get_user_by_id(db, ticket.user_id)
|
||||
except Exception:
|
||||
user = None
|
||||
full_name = user.full_name if user else "Unknown"
|
||||
telegram_id_display = user.telegram_id if user else "—"
|
||||
username_display = (user.username or "отсутствует") if user else "отсутствует"
|
||||
|
||||
# Обрезаем текст ответа для уведомления
|
||||
reply_preview = reply_text[:150] + "..." if len(reply_text) > 150 else reply_text
|
||||
|
||||
notification_text = (
|
||||
f"💬 <b>ОТВЕТ НА ТИКЕТ</b>\n\n"
|
||||
f"🆔 <b>ID тикета:</b> <code>{ticket.id}</code>\n"
|
||||
f"📝 <b>Заголовок:</b> {title or '—'}\n"
|
||||
f"👤 <b>Пользователь:</b> {full_name}\n"
|
||||
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
|
||||
f"📱 <b>Username:</b> @{username_display}\n\n"
|
||||
f"📩 <b>Сообщение:</b>\n{reply_preview}\n"
|
||||
)
|
||||
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
bot = maintenance_service._bot or None
|
||||
if bot is None:
|
||||
logger.warning("Bot instance is not available for admin notifications")
|
||||
return
|
||||
|
||||
service = AdminNotificationService(bot)
|
||||
result = await service.send_ticket_event_notification(notification_text, None)
|
||||
logger.info(f"Ticket #{ticket.id} reply notification sent: {result}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error notifying admins about ticket reply: {e}")
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
"""Регистрация обработчиков тикетов"""
|
||||
|
||||
|
||||
+36
-9
@@ -510,6 +510,7 @@ def get_main_menu_keyboard(
|
||||
# Добавляем кнопку докупки трафика для лимитированных подписок
|
||||
if (
|
||||
settings.BUY_TRAFFIC_BUTTON_VISIBLE
|
||||
and settings.is_traffic_topup_enabled()
|
||||
and subscription
|
||||
and not subscription.is_trial
|
||||
and (subscription.traffic_limit_gb or 0) > 0
|
||||
@@ -1699,7 +1700,7 @@ def get_add_traffic_keyboard(
|
||||
if months_multiplier > 1:
|
||||
period_text = f" (за {months_multiplier} мес)"
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
||||
|
||||
if not enabled_packages:
|
||||
@@ -1855,27 +1856,45 @@ def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, lang
|
||||
])
|
||||
|
||||
|
||||
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
def get_reset_traffic_confirm_keyboard(
|
||||
price_kopeks: int,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
has_enough_balance: bool = True,
|
||||
missing_kopeks: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
return get_back_keyboard(language)
|
||||
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
buttons = []
|
||||
|
||||
if has_enough_balance:
|
||||
# Достаточно средств - показываем кнопку сброса
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"✅ Сбросить за {settings.format_price(price_kopeks)}",
|
||||
callback_data="confirm_reset_traffic"
|
||||
)
|
||||
],
|
||||
[
|
||||
])
|
||||
else:
|
||||
# Не хватает средств - показываем кнопку пополнения
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PENDING_CANCEL_BUTTON", "⌛ Отмена"),
|
||||
callback_data="menu_subscription",
|
||||
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
|
||||
callback_data=f"topup_amount_{missing_kopeks}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="subscription_settings",
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def get_manage_countries_keyboard(
|
||||
countries: List[dict],
|
||||
@@ -2386,6 +2405,14 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_modem_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MODEM_BUTTON", "📡 Модем"),
|
||||
callback_data="subscription_modem"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
|
||||
|
||||
@@ -1520,6 +1520,7 @@
|
||||
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
|
||||
"TRAFFIC_TOPUP_DISABLED": "⚠️ Traffic top-up feature is disabled",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
|
||||
@@ -1619,5 +1620,36 @@
|
||||
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
|
||||
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
|
||||
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist"
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist",
|
||||
"CONTEST_START_ANNOUNCEMENT": "Game starts",
|
||||
"CONTEST_PRIZE": "Prize",
|
||||
"DAYS": "days subscription",
|
||||
"CONTEST_WINNERS": "Winners",
|
||||
"CONTEST_ATTEMPTS": "Attempts/user",
|
||||
"CONTEST_ELIGIBILITY": "Only users with active or trial subscription can participate",
|
||||
"REMINDER": "Reminder",
|
||||
"CONTEST_REMINDER_TEXT": "Don't forget to participate in contests to get bonuses",
|
||||
"MODEM_BUTTON": "📡 Modem",
|
||||
"MODEM_ENABLE_BUTTON": "✅ Connect modem",
|
||||
"MODEM_DISABLE_BUTTON": "❌ Disconnect modem",
|
||||
"MODEM_CONFIRM_BUTTON": "✅ Confirm connection",
|
||||
"MODEM_STATUS_ENABLED": "✅ Connected",
|
||||
"MODEM_STATUS_DISABLED": "❌ Not connected",
|
||||
"MODEM_PAID_ONLY": "⚠️ Modem is only available for paid subscriptions",
|
||||
"MODEM_DISABLED": "⚠️ Modem feature is disabled",
|
||||
"MODEM_ALREADY_ENABLED": "ℹ️ Modem is already connected",
|
||||
"MODEM_NOT_ENABLED": "ℹ️ Modem is not connected",
|
||||
"MODEM_INFO_ENABLED": "📡 <b>Modem</b>\n\nStatus: {status}\n\nModem is connected to your subscription.\nMonthly fee: {price}\n\nNo refunds are provided when disconnecting the modem.",
|
||||
"MODEM_INFO_DISABLED": "📡 <b>Modem</b>\n\nStatus: {status}\n\nConnect a modem to your subscription.\nMonthly fee: {price}\n\nWhen connecting a modem, an additional device will be added.",
|
||||
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Insufficient funds</b>\n\nModem connection cost: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method.",
|
||||
"MODEM_CONFIRM_ENABLE": "📡 <b>Confirm modem connection</b>\n\nCost: {price} (for {months} months)\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?",
|
||||
"MODEM_ENABLED_SUCCESS": "✅ <b>Modem successfully connected!</b>\n\n📡 Modem activated\n📱 Device added for modem\n",
|
||||
"MODEM_CHARGED": "💰 Charged: {amount}",
|
||||
"MODEM_DISABLED_SUCCESS": "✅ <b>Modem disconnected</b>\n\n📡 Modem deactivated\nℹ️ No refunds are provided",
|
||||
"MODEM_PERIOD_INFO": "\n📅 Modem valid until: <b>{end_date}</b> ({days} days)",
|
||||
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Warning!</b> Only <b>{days} days</b> left until subscription ends.\nAfter renewal, modem will need to be paid again!",
|
||||
"MODEM_PERIOD_NOTE": "\nℹ️ Days until subscription ends: <b>{days}</b>\nAfter renewal, modem will need to be paid again.",
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Cost: <s>{base_price}</s> <b>{final_price}</b> (for {months} months)\n🎁 Discount {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Cost: {price} (for {months} months)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Confirm modem connection</b>\n\n{price_text}\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?"
|
||||
}
|
||||
|
||||
@@ -1535,6 +1535,7 @@
|
||||
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
|
||||
"TRAFFIC_TOPUP_DISABLED": "⚠️ Функция докупки трафика отключена",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
|
||||
@@ -1634,5 +1635,36 @@
|
||||
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
|
||||
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
|
||||
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Черный список"
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Черный список",
|
||||
"CONTEST_START_ANNOUNCEMENT": "Стартует игра",
|
||||
"CONTEST_PRIZE": "Приз",
|
||||
"DAYS": "дн. подписки",
|
||||
"CONTEST_WINNERS": "Победителей",
|
||||
"CONTEST_ATTEMPTS": "Попыток/польз",
|
||||
"CONTEST_ELIGIBILITY": "Участвовать могут только с активной или триальной подпиской",
|
||||
"REMINDER": "Напоминание",
|
||||
"CONTEST_REMINDER_TEXT": "Не забудьте участвовать в конкурсах для получения бонусов",
|
||||
"MODEM_BUTTON": "📡 Модем",
|
||||
"MODEM_ENABLE_BUTTON": "✅ Подключить модем",
|
||||
"MODEM_DISABLE_BUTTON": "❌ Отключить модем",
|
||||
"MODEM_CONFIRM_BUTTON": "✅ Подтвердить подключение",
|
||||
"MODEM_STATUS_ENABLED": "✅ Подключен",
|
||||
"MODEM_STATUS_DISABLED": "❌ Не подключен",
|
||||
"MODEM_PAID_ONLY": "⚠️ Модем доступен только для платных подписок",
|
||||
"MODEM_DISABLED": "⚠️ Функция модема отключена",
|
||||
"MODEM_ALREADY_ENABLED": "ℹ️ Модем уже подключен",
|
||||
"MODEM_NOT_ENABLED": "ℹ️ Модем не подключен",
|
||||
"MODEM_INFO_ENABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nМодем подключен к вашей подписке.\nЕжемесячная плата: {price}\n\nПри отключении модема возврат средств не производится.",
|
||||
"MODEM_INFO_DISABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nПодключите модем к вашей подписке.\nЕжемесячная плата: {price}\n\nПри подключении модема будет добавлено дополнительное устройство.",
|
||||
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость подключения модема: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения.",
|
||||
"MODEM_CONFIRM_ENABLE": "📡 <b>Подтверждение подключения модема</b>\n\nСтоимость: {price} (за {months} мес)\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?",
|
||||
"MODEM_ENABLED_SUCCESS": "✅ <b>Модем успешно подключен!</b>\n\n📡 Модем активирован\n📱 Добавлено устройство для модема\n",
|
||||
"MODEM_CHARGED": "💰 Списано: {amount}",
|
||||
"MODEM_DISABLED_SUCCESS": "✅ <b>Модем отключен</b>\n\n📡 Модем деактивирован\nℹ️ Возврат средств не производится",
|
||||
"MODEM_PERIOD_INFO": "\n📅 Модем действует до: <b>{end_date}</b> ({days} дн.)",
|
||||
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\nПосле продления подписки модем нужно будет оплатить заново!",
|
||||
"MODEM_PERIOD_NOTE": "\nℹ️ До окончания подписки: <b>{days} дн.</b>\nПосле продления модем нужно будет оплатить заново.",
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n🎁 Скидка {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Стоимость: {price} (за {months} мес)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Подтверждение подключения модема</b>\n\n{price_text}\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?"
|
||||
}
|
||||
|
||||
@@ -1454,6 +1454,7 @@
|
||||
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас вже безлімітний трафік",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ У поточному режимі трафік фіксований і не може бути змінений",
|
||||
"TRAFFIC_TOPUP_DISABLED": "⚠️ Функція докупки трафіку вимкнена",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостатньо коштів!\nПотрібно: {required} (за {months} міс)\nУ вас: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Ліміт трафіку не змінився",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакети трафіку не налаштовані",
|
||||
@@ -1551,5 +1552,28 @@
|
||||
"POLL_EMPTY": "Опитування поки недоступне.",
|
||||
"POLL_ERROR": "Не вдалося обробити опитування. Спробуйте пізніше.",
|
||||
"POLL_COMPLETED": "🙏 Дякуємо за участь в опитуванні!",
|
||||
"POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс."
|
||||
"POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс.",
|
||||
"MODEM_BUTTON": "📡 Модем",
|
||||
"MODEM_ENABLE_BUTTON": "✅ Підключити модем",
|
||||
"MODEM_DISABLE_BUTTON": "❌ Відключити модем",
|
||||
"MODEM_CONFIRM_BUTTON": "✅ Підтвердити підключення",
|
||||
"MODEM_STATUS_ENABLED": "✅ Підключено",
|
||||
"MODEM_STATUS_DISABLED": "❌ Не підключено",
|
||||
"MODEM_PAID_ONLY": "⚠️ Модем доступний тільки для платних підписок",
|
||||
"MODEM_DISABLED": "⚠️ Функція модема вимкнена",
|
||||
"MODEM_ALREADY_ENABLED": "ℹ️ Модем вже підключено",
|
||||
"MODEM_NOT_ENABLED": "ℹ️ Модем не підключено",
|
||||
"MODEM_INFO_ENABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nМодем підключено до вашої підписки.\nЩомісячна плата: {price}\n\nПри відключенні модема повернення коштів не здійснюється.",
|
||||
"MODEM_INFO_DISABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nПідключіть модем до вашої підписки.\nЩомісячна плата: {price}\n\nПри підключенні модема буде додано додатковий пристрій.",
|
||||
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Недостатньо коштів</b>\n\nВартість підключення модема: {required}\nНа балансі: {balance}\nНе вистачає: {missing}\n\nОберіть спосіб поповнення.",
|
||||
"MODEM_CONFIRM_ENABLE": "📡 <b>Підтвердження підключення модема</b>\n\nВартість: {price} (за {months} міс)\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
|
||||
"MODEM_ENABLED_SUCCESS": "✅ <b>Модем успішно підключено!</b>\n\n📡 Модем активовано\n📱 Додано пристрій для модема\n",
|
||||
"MODEM_CHARGED": "💰 Списано: {amount}",
|
||||
"MODEM_DISABLED_SUCCESS": "✅ <b>Модем відключено</b>\n\n📡 Модем деактивовано\nℹ️ Повернення коштів не здійснюється",
|
||||
"MODEM_PERIOD_INFO": "\n📅 Модем діє до: <b>{end_date}</b> ({days} дн.)",
|
||||
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Увага!</b> До закінчення підписки залишилось лише <b>{days} дн.</b>\nПісля продовження підписки модем потрібно буде оплатити знову!",
|
||||
"MODEM_PERIOD_NOTE": "\nℹ️ До закінчення підписки: <b>{days} дн.</b>\nПісля продовження модем потрібно буде оплатити знову.",
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: <s>{base_price}</s> <b>{final_price}</b> (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?"
|
||||
}
|
||||
|
||||
@@ -1452,6 +1452,7 @@
|
||||
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
|
||||
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
|
||||
"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
|
||||
"TRAFFIC_NO_CHANGE":"ℹ️流量限制未更改",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
|
||||
@@ -1779,6 +1780,7 @@
|
||||
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
|
||||
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
|
||||
"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
|
||||
"TRAFFIC_NO_CHANGE":"ℹ️流量限制未更改",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
|
||||
@@ -1878,5 +1880,28 @@
|
||||
"POLL_COMPLETED":"🙏感谢您参与民意调查!",
|
||||
"POLL_REWARD_GRANTED":"奖励{amount}已存入您的余额。",
|
||||
"DEVICE_GUIDE_WINDOWS":"💻Windows",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前"
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前",
|
||||
"MODEM_BUTTON":"📡 调制解调器",
|
||||
"MODEM_ENABLE_BUTTON":"✅ 连接调制解调器",
|
||||
"MODEM_DISABLE_BUTTON":"❌ 断开调制解调器",
|
||||
"MODEM_CONFIRM_BUTTON":"✅ 确认连接",
|
||||
"MODEM_STATUS_ENABLED":"✅ 已连接",
|
||||
"MODEM_STATUS_DISABLED":"❌ 未连接",
|
||||
"MODEM_PAID_ONLY":"⚠️ 调制解调器仅适用于付费订阅",
|
||||
"MODEM_DISABLED":"⚠️ 调制解调器功能已禁用",
|
||||
"MODEM_ALREADY_ENABLED":"ℹ️ 调制解调器已连接",
|
||||
"MODEM_NOT_ENABLED":"ℹ️ 调制解调器未连接",
|
||||
"MODEM_INFO_ENABLED":"📡 <b>调制解调器</b>\n\n状态:{status}\n\n调制解调器已连接到您的订阅。\n月费:{price}\n\n断开调制解调器时不提供退款。",
|
||||
"MODEM_INFO_DISABLED":"📡 <b>调制解调器</b>\n\n状态:{status}\n\n将调制解调器连接到您的订阅。\n月费:{price}\n\n连接调制解调器时将添加额外设备。",
|
||||
"MODEM_INSUFFICIENT_FUNDS":"⚠️ <b>余额不足</b>\n\n调制解调器连接费用:{required}\n余额:{balance}\n差额:{missing}\n\n请选择充值方式。",
|
||||
"MODEM_CONFIRM_ENABLE":"📡 <b>确认连接调制解调器</b>\n\n费用:{price}({months}个月)\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
|
||||
"MODEM_ENABLED_SUCCESS":"✅ <b>调制解调器连接成功!</b>\n\n📡 调制解调器已激活\n📱 已添加调制解调器设备\n",
|
||||
"MODEM_CHARGED":"💰 已扣款:{amount}",
|
||||
"MODEM_DISABLED_SUCCESS":"✅ <b>调制解调器已断开</b>\n\n📡 调制解调器已停用\nℹ️ 不提供退款",
|
||||
"MODEM_PERIOD_INFO":"\n📅 调制解调器有效期至:<b>{end_date}</b>({days}天)",
|
||||
"MODEM_SHORT_PERIOD_WARNING":"\n⚠️ <b>注意!</b>距离订阅结束仅剩<b>{days}天</b>。\n续订后需重新支付调制解调器费用!",
|
||||
"MODEM_PERIOD_NOTE":"\nℹ️ 距离订阅结束:<b>{days}天</b>\n续订后需重新支付调制解调器费用。",
|
||||
"MODEM_PRICE_WITH_DISCOUNT":"费用:<s>{base_price}</s> <b>{final_price}</b>({months}个月)\n🎁 折扣{discount}%:-{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT":"费用:{price}({months}个月)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE":"📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?"
|
||||
}
|
||||
|
||||
@@ -108,8 +108,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
elif member.status in self.BAD_MEMBER_STATUS:
|
||||
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
|
||||
|
||||
if telegram_id and settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
|
||||
await self._deactivate_subscription_on_unsubscribe(telegram_id)
|
||||
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
|
||||
@@ -253,8 +253,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
finally:
|
||||
break
|
||||
|
||||
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
||||
async def _deactivate_subscription_on_unsubscribe(self, telegram_id: int) -> None:
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
logger.debug(
|
||||
"ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено",
|
||||
telegram_id,
|
||||
@@ -272,19 +272,29 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
break
|
||||
|
||||
subscription = user.subscription
|
||||
if (not subscription.is_trial or
|
||||
subscription.status != SubscriptionStatus.ACTIVE.value):
|
||||
|
||||
if subscription.status != SubscriptionStatus.ACTIVE.value:
|
||||
logger.debug(
|
||||
"ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)",
|
||||
"ℹ️ Подписка пользователя %s не активна (status=%s) — пропускаем деактивацию",
|
||||
telegram_id,
|
||||
subscription.is_trial,
|
||||
subscription.status,
|
||||
)
|
||||
break
|
||||
|
||||
if settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
pass
|
||||
elif not subscription.is_trial:
|
||||
logger.debug(
|
||||
"ℹ️ Подписка пользователя %s платная, CHANNEL_REQUIRED_FOR_ALL=False — пропускаем деактивацию",
|
||||
telegram_id,
|
||||
)
|
||||
break
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
sub_type = "Триальная" if subscription.is_trial else "Платная"
|
||||
logger.info(
|
||||
"🚫 Триальная подписка пользователя %s отключена после отписки от канала",
|
||||
"🚫 %s подписка пользователя %s отключена после отписки от канала",
|
||||
sub_type,
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -1510,6 +1510,7 @@ class AdminNotificationService:
|
||||
except Exception:
|
||||
runtime_enabled = True
|
||||
if not (self._is_enabled() and runtime_enabled):
|
||||
logger.info(f"Ticket notification skipped: _is_enabled={self._is_enabled()}, runtime_enabled={runtime_enabled}")
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
|
||||
@@ -147,6 +147,9 @@ class AdvertisingCampaignService:
|
||||
duration_days=duration_days,
|
||||
traffic_limit_gb=traffic_limit or 0,
|
||||
device_limit=device_limit,
|
||||
connected_squads=squads,
|
||||
update_server_counters=True,
|
||||
is_trial=True,
|
||||
squad_uuid=squad_uuid,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta, time, timezone
|
||||
from typing import Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -18,17 +17,19 @@ from app.database.crud.contest import (
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ContestTemplate, SubscriptionStatus, User
|
||||
from app.services.contests.enums import GameType, PrizeType, RoundStatus
|
||||
from app.services.contests.games import get_game_strategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Slugs for games
|
||||
GAME_QUEST = "quest_buttons"
|
||||
GAME_LOCKS = "lock_hack"
|
||||
GAME_CIPHER = "letter_cipher"
|
||||
GAME_SERVER = "server_lottery"
|
||||
GAME_BLITZ = "blitz_reaction"
|
||||
GAME_EMOJI = "emoji_guess"
|
||||
GAME_ANAGRAM = "anagram"
|
||||
# Legacy aliases for backward compatibility
|
||||
GAME_QUEST = GameType.QUEST_BUTTONS.value
|
||||
GAME_LOCKS = GameType.LOCK_HACK.value
|
||||
GAME_CIPHER = GameType.LETTER_CIPHER.value
|
||||
GAME_SERVER = GameType.SERVER_LOTTERY.value
|
||||
GAME_BLITZ = GameType.BLITZ_REACTION.value
|
||||
GAME_EMOJI = GameType.EMOJI_GUESS.value
|
||||
GAME_ANAGRAM = GameType.ANAGRAM.value
|
||||
|
||||
|
||||
DEFAULT_TEMPLATES = [
|
||||
@@ -36,7 +37,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_QUEST,
|
||||
"name": "Квест-кнопки",
|
||||
"description": "Найди секретную кнопку 3×3",
|
||||
"prize_days": 1,
|
||||
"prize_type": "days",
|
||||
"prize_value": "1",
|
||||
"max_winners": 3,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
@@ -48,7 +50,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_LOCKS,
|
||||
"name": "Кнопочный взлом",
|
||||
"description": "Найди взломанную кнопку среди 20 замков",
|
||||
"prize_days": 5,
|
||||
"prize_type": "days",
|
||||
"prize_value": "5",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
@@ -60,7 +63,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_CIPHER,
|
||||
"name": "Шифр букв",
|
||||
"description": "Расшифруй слово по номерам",
|
||||
"prize_days": 1,
|
||||
"prize_type": "days",
|
||||
"prize_value": "1",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
@@ -72,7 +76,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_SERVER,
|
||||
"name": "Сервер-лотерея",
|
||||
"description": "Угадай доступный сервер",
|
||||
"prize_days": 7,
|
||||
"prize_type": "days",
|
||||
"prize_value": "7",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
@@ -84,7 +89,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_BLITZ,
|
||||
"name": "Блиц-реакция",
|
||||
"description": "Нажми кнопку за 10 секунд",
|
||||
"prize_days": 1,
|
||||
"prize_type": "days",
|
||||
"prize_value": "1",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
@@ -96,7 +102,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_EMOJI,
|
||||
"name": "Угадай сервис по эмодзи",
|
||||
"description": "Определи сервис по эмодзи",
|
||||
"prize_days": 1,
|
||||
"prize_type": "days",
|
||||
"prize_value": "1",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
@@ -108,7 +115,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_ANAGRAM,
|
||||
"name": "Анаграмма дня",
|
||||
"description": "Собери слово из букв",
|
||||
"prize_days": 1,
|
||||
"prize_type": "days",
|
||||
"prize_value": "1",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
@@ -239,38 +247,12 @@ class ContestRotationService:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict:
|
||||
payload = tpl.payload or {}
|
||||
if tpl.slug == GAME_QUEST:
|
||||
rows = payload.get("rows", 3)
|
||||
cols = payload.get("cols", 3)
|
||||
total = rows * cols
|
||||
secret_idx = random.randint(0, total - 1)
|
||||
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_LOCKS:
|
||||
total = payload.get("buttons", 20)
|
||||
secret_idx = random.randint(0, max(0, total - 1))
|
||||
return {"total": total, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_CIPHER:
|
||||
words = payload.get("words") or ["VPN"]
|
||||
word = random.choice(words)
|
||||
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
|
||||
return {"question": "-".join(codes), "answer": word.upper()}
|
||||
if tpl.slug == GAME_SERVER:
|
||||
flags = payload.get("flags") or ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]
|
||||
secret_idx = random.randint(0, len(flags) - 1)
|
||||
return {"flags": flags, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_BLITZ:
|
||||
return {"timeout_seconds": payload.get("timeout_seconds", 10)}
|
||||
if tpl.slug == GAME_EMOJI:
|
||||
pairs = payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
|
||||
pair = random.choice(pairs)
|
||||
return pair
|
||||
if tpl.slug == GAME_ANAGRAM:
|
||||
words = payload.get("words") or ["SERVER"]
|
||||
word = random.choice(words).upper()
|
||||
shuffled = "".join(random.sample(word, len(word)))
|
||||
return {"letters": shuffled, "answer": word}
|
||||
return payload
|
||||
"""Build round-specific payload using game strategy."""
|
||||
strategy = get_game_strategy(tpl.slug)
|
||||
if strategy:
|
||||
return strategy.build_payload(tpl.payload or {})
|
||||
# Fallback for unknown game types
|
||||
return tpl.payload or {}
|
||||
|
||||
async def _announce_round_start(
|
||||
self,
|
||||
@@ -281,11 +263,28 @@ class ContestRotationService:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts("ru") # Default to ru for announcements
|
||||
|
||||
# Format prize display based on prize_type
|
||||
prize_type = tpl.prize_type or PrizeType.DAYS.value
|
||||
prize_value = tpl.prize_value or "1"
|
||||
|
||||
if prize_type == PrizeType.DAYS.value:
|
||||
prize_display = f"{prize_value} {texts.t('DAYS', 'дн. подписки')}"
|
||||
elif prize_type == PrizeType.BALANCE.value:
|
||||
prize_display = f"{prize_value} коп."
|
||||
elif prize_type == PrizeType.CUSTOM.value:
|
||||
prize_display = prize_value
|
||||
else:
|
||||
prize_display = prize_value
|
||||
|
||||
text = (
|
||||
f"🎲 Стартует игра: <b>{tpl.name}</b>\n"
|
||||
f"Приз: {tpl.prize_days} дн. подписки • Победителей: {tpl.max_winners}\n"
|
||||
f"Попыток/польз: {tpl.attempts_per_user}\n\n"
|
||||
"Участвовать могут только с активной или триальной подпиской."
|
||||
f"🎲 {texts.t('CONTEST_START_ANNOUNCEMENT', 'Стартует игра')}: <b>{tpl.name}</b>\n"
|
||||
f"{texts.t('CONTEST_PRIZE', 'Приз')}: {prize_display} • {texts.t('CONTEST_WINNERS', 'Победителей')}: {tpl.max_winners}\n"
|
||||
f"{texts.t('CONTEST_ATTEMPTS', 'Попыток/польз')}: {tpl.attempts_per_user}\n\n"
|
||||
f"{texts.t('CONTEST_ELIGIBILITY', 'Участвовать могут только с активной или триальной подпиской')}.\n"
|
||||
f"💡 <b>{texts.t('REMINDER', 'Напоминание')}:</b> {texts.t('CONTEST_REMINDER_TEXT', 'Не забудьте участвовать в конкурсах для получения бонусов')}!"
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Contest services module."""
|
||||
|
||||
from app.services.contests.enums import GameType, RoundStatus, PrizeType
|
||||
from app.services.contests.games import get_game_strategy, BaseGameStrategy
|
||||
from app.services.contests.attempt_service import ContestAttemptService
|
||||
|
||||
__all__ = [
|
||||
"GameType",
|
||||
"RoundStatus",
|
||||
"PrizeType",
|
||||
"get_game_strategy",
|
||||
"BaseGameStrategy",
|
||||
"ContestAttemptService",
|
||||
]
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Service for atomic contest attempt operations."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import create_attempt, get_attempt, update_attempt
|
||||
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import ContestAttempt, ContestRound, ContestTemplate
|
||||
from app.services.contests.enums import PrizeType
|
||||
from app.services.contests.games import get_game_strategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttemptResult:
|
||||
"""Result of processing a contest attempt."""
|
||||
|
||||
success: bool
|
||||
is_winner: bool
|
||||
message: str
|
||||
already_played: bool = False
|
||||
round_finished: bool = False
|
||||
|
||||
|
||||
class ContestAttemptService:
|
||||
"""Service for processing contest attempts with atomic operations."""
|
||||
|
||||
async def process_button_attempt(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
round_obj: ContestRound,
|
||||
user_id: int,
|
||||
pick: str,
|
||||
language: str,
|
||||
) -> AttemptResult:
|
||||
"""
|
||||
Process a button-based game attempt atomically.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
round_obj: Contest round
|
||||
user_id: User ID
|
||||
pick: User's pick (button callback data)
|
||||
language: User's language
|
||||
|
||||
Returns:
|
||||
AttemptResult with outcome details
|
||||
"""
|
||||
tpl = round_obj.template
|
||||
if not tpl:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="Конкурс не найден",
|
||||
)
|
||||
|
||||
# Check if user already played
|
||||
existing_attempt = await get_attempt(db, round_obj.id, user_id)
|
||||
if existing_attempt:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="У вас уже была попытка",
|
||||
already_played=True,
|
||||
)
|
||||
|
||||
# Get game strategy and check answer
|
||||
strategy = get_game_strategy(tpl.slug)
|
||||
if not strategy:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="Тип игры не поддерживается",
|
||||
)
|
||||
|
||||
check_result = strategy.check_answer(pick, round_obj.payload or {}, language)
|
||||
is_winner = check_result.is_correct
|
||||
|
||||
# Atomic winner check with row lock
|
||||
is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
|
||||
|
||||
# Create attempt record
|
||||
await create_attempt(
|
||||
db,
|
||||
round_id=round_obj.id,
|
||||
user_id=user_id,
|
||||
answer=str(pick),
|
||||
is_winner=is_winner,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Contest attempt: user %s, round %s, pick '%s', winner %s",
|
||||
user_id, round_obj.id, pick, is_winner
|
||||
)
|
||||
|
||||
if is_winner:
|
||||
prize_msg = await self._award_prize(db, user_id, tpl, language)
|
||||
return AttemptResult(
|
||||
success=True,
|
||||
is_winner=True,
|
||||
message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
|
||||
)
|
||||
|
||||
return AttemptResult(
|
||||
success=True,
|
||||
is_winner=False,
|
||||
message=check_result.response_text or "Неудача",
|
||||
)
|
||||
|
||||
async def process_text_attempt(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
round_obj: ContestRound,
|
||||
user_id: int,
|
||||
text_answer: str,
|
||||
language: str,
|
||||
) -> AttemptResult:
|
||||
"""
|
||||
Process a text-input game attempt atomically.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
round_obj: Contest round
|
||||
user_id: User ID
|
||||
text_answer: User's text answer
|
||||
language: User's language
|
||||
|
||||
Returns:
|
||||
AttemptResult with outcome details
|
||||
"""
|
||||
tpl = round_obj.template
|
||||
if not tpl:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="Конкурс не найден",
|
||||
)
|
||||
|
||||
# For text games, attempt should already exist (created in render phase)
|
||||
attempt = await get_attempt(db, round_obj.id, user_id)
|
||||
if not attempt:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="Сначала начните игру",
|
||||
)
|
||||
|
||||
# Check if already answered
|
||||
if attempt.answer is not None:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="У вас уже была попытка",
|
||||
already_played=True,
|
||||
)
|
||||
|
||||
# Get game strategy and check answer
|
||||
strategy = get_game_strategy(tpl.slug)
|
||||
if not strategy:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="Тип игры не поддерживается",
|
||||
)
|
||||
|
||||
check_result = strategy.check_answer(text_answer, round_obj.payload or {}, language)
|
||||
is_winner = check_result.is_correct
|
||||
|
||||
# Atomic winner check with row lock
|
||||
is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
|
||||
|
||||
# Update attempt with answer
|
||||
await update_attempt(db, attempt, answer=text_answer.strip().upper(), is_winner=is_winner)
|
||||
|
||||
logger.info(
|
||||
"Contest text attempt: user %s, round %s, answer '%s', winner %s",
|
||||
user_id, round_obj.id, text_answer, is_winner
|
||||
)
|
||||
|
||||
if is_winner:
|
||||
prize_msg = await self._award_prize(db, user_id, tpl, language)
|
||||
return AttemptResult(
|
||||
success=True,
|
||||
is_winner=True,
|
||||
message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
|
||||
)
|
||||
|
||||
return AttemptResult(
|
||||
success=True,
|
||||
is_winner=False,
|
||||
message=check_result.response_text or "Неверно, попробуй в следующем раунде",
|
||||
)
|
||||
|
||||
async def create_pending_attempt(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
round_id: int,
|
||||
user_id: int,
|
||||
) -> Optional[ContestAttempt]:
|
||||
"""
|
||||
Create a pending attempt for text-input games.
|
||||
This blocks re-entry while user is answering.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
round_id: Round ID
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Created attempt or None if already exists
|
||||
"""
|
||||
existing = await get_attempt(db, round_id, user_id)
|
||||
if existing:
|
||||
return None
|
||||
|
||||
return await create_attempt(
|
||||
db,
|
||||
round_id=round_id,
|
||||
user_id=user_id,
|
||||
answer=None,
|
||||
is_winner=False,
|
||||
)
|
||||
|
||||
async def _atomic_winner_check(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
round_id: int,
|
||||
is_winner: bool,
|
||||
) -> bool:
|
||||
"""
|
||||
Atomically check and increment winner count.
|
||||
Uses SELECT FOR UPDATE to prevent race conditions.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
round_id: Round ID
|
||||
is_winner: Whether user answered correctly
|
||||
|
||||
Returns:
|
||||
True if user is a winner, False if max winners reached
|
||||
"""
|
||||
if not is_winner:
|
||||
return False
|
||||
|
||||
stmt = select(ContestRound).where(ContestRound.id == round_id).with_for_update()
|
||||
result = await db.execute(stmt)
|
||||
round_obj = result.scalar_one()
|
||||
|
||||
if round_obj.winners_count >= round_obj.max_winners:
|
||||
return False
|
||||
|
||||
round_obj.winners_count += 1
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
async def _award_prize(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
template: ContestTemplate,
|
||||
language: str,
|
||||
) -> str:
|
||||
"""
|
||||
Award prize to winner.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Winner user ID
|
||||
template: Contest template with prize info
|
||||
language: User's language
|
||||
|
||||
Returns:
|
||||
Prize notification message
|
||||
"""
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts(language)
|
||||
|
||||
prize_type = template.prize_type or PrizeType.DAYS.value
|
||||
prize_value = template.prize_value or "1"
|
||||
|
||||
if prize_type == PrizeType.DAYS.value:
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
return ""
|
||||
days = int(prize_value) if prize_value.isdigit() else 1
|
||||
await extend_subscription(db, subscription, days)
|
||||
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=days)
|
||||
|
||||
elif prize_type == PrizeType.BALANCE.value:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return ""
|
||||
kopeks = int(prize_value) if prize_value.isdigit() else 0
|
||||
if kopeks > 0:
|
||||
user.balance_kopeks += kopeks
|
||||
await db.commit()
|
||||
return texts.t(
|
||||
"CONTEST_BALANCE_GRANTED",
|
||||
"Бонус {amount} зачислен!"
|
||||
).format(amount=settings.format_price(kopeks))
|
||||
|
||||
elif prize_type == PrizeType.CUSTOM.value:
|
||||
return f"🎁 {prize_value}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# Singleton instance
|
||||
contest_attempt_service = ContestAttemptService()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Enum classes for contest system."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class GameType(str, Enum):
|
||||
"""Types of daily contest games."""
|
||||
|
||||
QUEST_BUTTONS = "quest_buttons"
|
||||
LOCK_HACK = "lock_hack"
|
||||
LETTER_CIPHER = "letter_cipher"
|
||||
SERVER_LOTTERY = "server_lottery"
|
||||
BLITZ_REACTION = "blitz_reaction"
|
||||
EMOJI_GUESS = "emoji_guess"
|
||||
ANAGRAM = "anagram"
|
||||
|
||||
@classmethod
|
||||
def is_text_input(cls, game_type: "GameType") -> bool:
|
||||
"""Check if game requires text input from user."""
|
||||
return game_type in {cls.LETTER_CIPHER, cls.EMOJI_GUESS, cls.ANAGRAM}
|
||||
|
||||
@classmethod
|
||||
def is_button_pick(cls, game_type: "GameType") -> bool:
|
||||
"""Check if game uses button selection."""
|
||||
return game_type in {
|
||||
cls.QUEST_BUTTONS,
|
||||
cls.LOCK_HACK,
|
||||
cls.SERVER_LOTTERY,
|
||||
cls.BLITZ_REACTION,
|
||||
}
|
||||
|
||||
|
||||
class RoundStatus(str, Enum):
|
||||
"""Contest round status."""
|
||||
|
||||
ACTIVE = "active"
|
||||
FINISHED = "finished"
|
||||
|
||||
|
||||
class PrizeType(str, Enum):
|
||||
"""Types of prizes for contests."""
|
||||
|
||||
DAYS = "days"
|
||||
BALANCE = "balance"
|
||||
CUSTOM = "custom"
|
||||
@@ -0,0 +1,473 @@
|
||||
"""Game strategies for different contest types."""
|
||||
|
||||
import random
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from aiogram import types
|
||||
|
||||
from app.services.contests.enums import GameType
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameRenderResult:
|
||||
"""Result of rendering a game."""
|
||||
|
||||
text: str
|
||||
keyboard: types.InlineKeyboardMarkup
|
||||
requires_text_input: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnswerCheckResult:
|
||||
"""Result of checking user's answer."""
|
||||
|
||||
is_correct: bool
|
||||
response_text: str
|
||||
|
||||
|
||||
class BaseGameStrategy(ABC):
|
||||
"""Base class for game strategies."""
|
||||
|
||||
game_type: GameType
|
||||
|
||||
@abstractmethod
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build round-specific payload from template config."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
"""Render game UI for user."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
"""Check if user's answer is correct."""
|
||||
pass
|
||||
|
||||
def _get_back_button(self, language: str, callback: str) -> types.InlineKeyboardButton:
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts(language)
|
||||
return types.InlineKeyboardButton(text=texts.BACK, callback_data=callback)
|
||||
|
||||
def _get_texts(self, language: str):
|
||||
from app.localization.texts import get_texts
|
||||
return get_texts(language)
|
||||
|
||||
|
||||
class QuestButtonsStrategy(BaseGameStrategy):
|
||||
"""3x3 grid game - find the secret button."""
|
||||
|
||||
game_type = GameType.QUEST_BUTTONS
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rows = template_payload.get("rows", 3)
|
||||
cols = template_payload.get("cols", 3)
|
||||
total = rows * cols
|
||||
secret_idx = random.randint(0, total - 1)
|
||||
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
rows = payload.get("rows", 3)
|
||||
cols = payload.get("cols", 3)
|
||||
|
||||
keyboard_rows = []
|
||||
for r in range(rows):
|
||||
row_buttons = []
|
||||
for c in range(cols):
|
||||
idx = r * cols + c
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="🎛",
|
||||
callback_data=f"contest_pick_{round_id}_quest_{idx}",
|
||||
)
|
||||
)
|
||||
keyboard_rows.append(row_buttons)
|
||||
keyboard_rows.append([self._get_back_button(language, back_callback)])
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
|
||||
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
secret_idx = payload.get("secret_idx")
|
||||
try:
|
||||
if user_answer.startswith("quest_"):
|
||||
idx = int(user_answer.split("_")[1])
|
||||
is_correct = secret_idx is not None and idx == secret_idx
|
||||
else:
|
||||
is_correct = False
|
||||
except (ValueError, IndexError):
|
||||
is_correct = False
|
||||
|
||||
responses = ["Пусто", "Ложный сервер", "Найди другой узел"]
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else random.choice(responses),
|
||||
)
|
||||
|
||||
|
||||
class LockHackStrategy(BaseGameStrategy):
|
||||
"""20 locks game - find the hacked one."""
|
||||
|
||||
game_type = GameType.LOCK_HACK
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
total = template_payload.get("buttons", 20)
|
||||
secret_idx = random.randint(0, max(0, total - 1))
|
||||
return {"total": total, "secret_idx": secret_idx}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
total = payload.get("total", 20)
|
||||
|
||||
keyboard_rows = []
|
||||
row = []
|
||||
for i in range(total):
|
||||
row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="🔒",
|
||||
callback_data=f"contest_pick_{round_id}_locks_{i}",
|
||||
)
|
||||
)
|
||||
if len(row) == 5:
|
||||
keyboard_rows.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard_rows.append(row)
|
||||
keyboard_rows.append([self._get_back_button(language, back_callback)])
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
|
||||
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
secret_idx = payload.get("secret_idx")
|
||||
try:
|
||||
if user_answer.startswith("locks_"):
|
||||
idx = int(user_answer.split("_")[1])
|
||||
is_correct = secret_idx is not None and idx == secret_idx
|
||||
else:
|
||||
is_correct = False
|
||||
except (ValueError, IndexError):
|
||||
is_correct = False
|
||||
|
||||
responses = ["Заблокировано", "Попробуй ещё", "Нет доступа"]
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else random.choice(responses),
|
||||
)
|
||||
|
||||
|
||||
class ServerLotteryStrategy(BaseGameStrategy):
|
||||
"""Flag lottery game - pick the correct server flag."""
|
||||
|
||||
game_type = GameType.SERVER_LOTTERY
|
||||
|
||||
DEFAULT_FLAGS = ["🇸🇪", "🇸🇬", "🇺🇸", "🇷🇺", "🇩🇪", "🇯🇵", "🇧🇷", "🇦🇺", "🇨🇦", "🇫🇷"]
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
flags = template_payload.get("flags") or self.DEFAULT_FLAGS
|
||||
secret_idx = random.randint(0, len(flags) - 1)
|
||||
return {"flags": flags, "secret_idx": secret_idx}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
flags = payload.get("flags") or []
|
||||
shuffled_flags = flags.copy()
|
||||
random.shuffle(shuffled_flags)
|
||||
|
||||
keyboard_rows = []
|
||||
row = []
|
||||
for flag in shuffled_flags:
|
||||
row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=flag,
|
||||
callback_data=f"contest_pick_{round_id}_{flag}",
|
||||
)
|
||||
)
|
||||
if len(row) == 5:
|
||||
keyboard_rows.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard_rows.append(row)
|
||||
keyboard_rows.append([self._get_back_button(language, back_callback)])
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
|
||||
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
secret_idx = payload.get("secret_idx")
|
||||
flags = payload.get("flags") or []
|
||||
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
|
||||
is_correct = user_answer == correct_flag
|
||||
|
||||
responses = ["Сервер перегружен", "Нет ответа", "Попробуй завтра"]
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else random.choice(responses),
|
||||
)
|
||||
|
||||
|
||||
class BlitzReactionStrategy(BaseGameStrategy):
|
||||
"""Blitz reaction game - press button quickly."""
|
||||
|
||||
game_type = GameType.BLITZ_REACTION
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"timeout_seconds": template_payload.get("timeout_seconds", 10)}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"),
|
||||
callback_data=f"contest_pick_{round_id}_blitz",
|
||||
)
|
||||
],
|
||||
[self._get_back_button(language, back_callback)],
|
||||
]
|
||||
)
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
|
||||
keyboard=keyboard,
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
is_correct = user_answer == "blitz"
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else "Время вышло",
|
||||
)
|
||||
|
||||
|
||||
class LetterCipherStrategy(BaseGameStrategy):
|
||||
"""Letter cipher game - decode word from letter codes."""
|
||||
|
||||
game_type = GameType.LETTER_CIPHER
|
||||
|
||||
DEFAULT_WORDS = ["VPN", "SERVER", "PROXY", "XRAY"]
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
words = template_payload.get("words") or self.DEFAULT_WORDS
|
||||
word = random.choice(words)
|
||||
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
|
||||
return {"question": "-".join(codes), "answer": word.upper()}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
question = payload.get("question", "")
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
|
||||
keyboard=get_back_keyboard(language),
|
||||
requires_text_input=True,
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
correct = (payload.get("answer") or "").upper()
|
||||
is_correct = correct and user_answer.strip().upper() == correct
|
||||
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
|
||||
)
|
||||
|
||||
|
||||
class EmojiGuessStrategy(BaseGameStrategy):
|
||||
"""Emoji guess game - guess service by emoji."""
|
||||
|
||||
game_type = GameType.EMOJI_GUESS
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pairs = template_payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
|
||||
pair = random.choice(pairs)
|
||||
return pair
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
question = payload.get("question", "🤔")
|
||||
emoji_list = question.split()
|
||||
random.shuffle(emoji_list)
|
||||
shuffled_question = " ".join(emoji_list)
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(
|
||||
q=shuffled_question
|
||||
),
|
||||
keyboard=get_back_keyboard(language),
|
||||
requires_text_input=True,
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
correct = (payload.get("answer") or "").upper()
|
||||
is_correct = correct and user_answer.strip().upper() == correct
|
||||
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
|
||||
)
|
||||
|
||||
|
||||
class AnagramStrategy(BaseGameStrategy):
|
||||
"""Anagram game - unscramble letters to form a word."""
|
||||
|
||||
game_type = GameType.ANAGRAM
|
||||
|
||||
DEFAULT_WORDS = ["SERVER", "XRAY", "VPN"]
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
words = template_payload.get("words") or self.DEFAULT_WORDS
|
||||
word = random.choice(words).upper()
|
||||
shuffled = "".join(random.sample(word, len(word)))
|
||||
return {"letters": shuffled, "answer": word}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
letters = payload.get("letters", "")
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(
|
||||
letters=letters
|
||||
),
|
||||
keyboard=get_back_keyboard(language),
|
||||
requires_text_input=True,
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
correct = (payload.get("answer") or "").upper()
|
||||
is_correct = correct and user_answer.strip().upper() == correct
|
||||
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
|
||||
)
|
||||
|
||||
|
||||
# Registry of game strategies
|
||||
_GAME_STRATEGIES: Dict[GameType, BaseGameStrategy] = {
|
||||
GameType.QUEST_BUTTONS: QuestButtonsStrategy(),
|
||||
GameType.LOCK_HACK: LockHackStrategy(),
|
||||
GameType.SERVER_LOTTERY: ServerLotteryStrategy(),
|
||||
GameType.BLITZ_REACTION: BlitzReactionStrategy(),
|
||||
GameType.LETTER_CIPHER: LetterCipherStrategy(),
|
||||
GameType.EMOJI_GUESS: EmojiGuessStrategy(),
|
||||
GameType.ANAGRAM: AnagramStrategy(),
|
||||
}
|
||||
|
||||
|
||||
def get_game_strategy(game_type: GameType | str) -> Optional[BaseGameStrategy]:
|
||||
"""Get game strategy by type."""
|
||||
if isinstance(game_type, str):
|
||||
try:
|
||||
game_type = GameType(game_type)
|
||||
except ValueError:
|
||||
return None
|
||||
return _GAME_STRATEGIES.get(game_type)
|
||||
|
||||
|
||||
def get_all_game_types() -> List[GameType]:
|
||||
"""Get list of all supported game types."""
|
||||
return list(_GAME_STRATEGIES.keys())
|
||||
@@ -128,7 +128,7 @@ DEFAULT_MENU_CONFIG: Dict[str, Any] = {
|
||||
"action": "buy_traffic",
|
||||
"enabled": True,
|
||||
"visibility": "subscribers",
|
||||
"conditions": {"has_traffic_limit": True},
|
||||
"conditions": {"has_traffic_limit": True, "traffic_topup_enabled": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"balance": {
|
||||
|
||||
@@ -715,6 +715,11 @@ class MenuLayoutService:
|
||||
if is_trial or traffic_limit <= 0:
|
||||
return False
|
||||
|
||||
# traffic_topup_enabled - функция докупки трафика включена
|
||||
if conditions.get("traffic_topup_enabled") is True:
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
return False
|
||||
|
||||
# is_admin
|
||||
if conditions.get("is_admin") is True:
|
||||
if not context.is_admin:
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Сервис для управления модемом в подписке.
|
||||
|
||||
Модем - это дополнительное устройство, которое можно подключить к подписке
|
||||
за отдельную плату. При подключении увеличивается лимит устройств.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User, TransactionType
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModemError(Enum):
|
||||
"""Типы ошибок при работе с модемом."""
|
||||
NO_SUBSCRIPTION = "no_subscription"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
MODEM_DISABLED = "modem_disabled"
|
||||
ALREADY_ENABLED = "already_enabled"
|
||||
NOT_ENABLED = "not_enabled"
|
||||
INSUFFICIENT_FUNDS = "insufficient_funds"
|
||||
CHARGE_ERROR = "charge_error"
|
||||
UPDATE_ERROR = "update_error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemAvailabilityResult:
|
||||
"""Результат проверки доступности модема."""
|
||||
available: bool
|
||||
error: Optional[ModemError] = None
|
||||
modem_enabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemPriceResult:
|
||||
"""Результат расчёта цены модема."""
|
||||
base_price: int
|
||||
final_price: int
|
||||
discount_percent: int
|
||||
discount_amount: int
|
||||
charged_months: int
|
||||
remaining_days: int
|
||||
end_date: datetime
|
||||
|
||||
@property
|
||||
def has_discount(self) -> bool:
|
||||
return self.discount_percent > 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemEnableResult:
|
||||
"""Результат подключения модема."""
|
||||
success: bool
|
||||
error: Optional[ModemError] = None
|
||||
charged_amount: int = 0
|
||||
new_device_limit: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemDisableResult:
|
||||
"""Результат отключения модема."""
|
||||
success: bool
|
||||
error: Optional[ModemError] = None
|
||||
new_device_limit: int = 0
|
||||
|
||||
|
||||
# Константы для предупреждений о сроке действия
|
||||
MODEM_WARNING_DAYS_CRITICAL = 7
|
||||
MODEM_WARNING_DAYS_INFO = 30
|
||||
|
||||
|
||||
class ModemService:
|
||||
"""
|
||||
Сервис для управления модемом в подписке.
|
||||
|
||||
Инкапсулирует всю бизнес-логику:
|
||||
- Проверки доступности
|
||||
- Расчёт цен и скидок
|
||||
- Подключение/отключение модема
|
||||
- Синхронизация с RemnaWave
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._subscription_service = SubscriptionService()
|
||||
|
||||
@staticmethod
|
||||
def is_modem_feature_enabled() -> bool:
|
||||
"""Проверяет, включена ли функция модема в настройках."""
|
||||
return settings.is_modem_enabled()
|
||||
|
||||
@staticmethod
|
||||
def get_modem_enabled(subscription: Optional[Subscription]) -> bool:
|
||||
"""Безопасно получает статус модема из подписки."""
|
||||
if subscription is None:
|
||||
return False
|
||||
return getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
def check_availability(
|
||||
self,
|
||||
user: User,
|
||||
for_enable: bool = False,
|
||||
for_disable: bool = False
|
||||
) -> ModemAvailabilityResult:
|
||||
"""
|
||||
Проверяет доступность модема для пользователя.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
for_enable: Проверка для подключения (модем должен быть отключен)
|
||||
for_disable: Проверка для отключения (модем должен быть включен)
|
||||
|
||||
Returns:
|
||||
ModemAvailabilityResult с результатом проверки
|
||||
"""
|
||||
subscription = user.subscription
|
||||
modem_enabled = self.get_modem_enabled(subscription)
|
||||
|
||||
if not subscription:
|
||||
return ModemAvailabilityResult(
|
||||
available=False,
|
||||
error=ModemError.NO_SUBSCRIPTION,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
return ModemAvailabilityResult(
|
||||
available=False,
|
||||
error=ModemError.TRIAL_SUBSCRIPTION,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if not self.is_modem_feature_enabled():
|
||||
return ModemAvailabilityResult(
|
||||
available=False,
|
||||
error=ModemError.MODEM_DISABLED,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if for_enable and modem_enabled:
|
||||
return ModemAvailabilityResult(
|
||||
available=False,
|
||||
error=ModemError.ALREADY_ENABLED,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if for_disable and not modem_enabled:
|
||||
return ModemAvailabilityResult(
|
||||
available=False,
|
||||
error=ModemError.NOT_ENABLED,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
return ModemAvailabilityResult(
|
||||
available=True,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
def calculate_price(self, subscription: Subscription) -> ModemPriceResult:
|
||||
"""
|
||||
Рассчитывает стоимость подключения модема.
|
||||
|
||||
Использует пропорциональную цену на основе оставшегося времени подписки
|
||||
и применяет скидки в зависимости от периода.
|
||||
|
||||
Args:
|
||||
subscription: Подписка пользователя
|
||||
|
||||
Returns:
|
||||
ModemPriceResult с детализацией цены
|
||||
"""
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
|
||||
base_price, charged_months = calculate_prorated_price(
|
||||
modem_price_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
remaining_days = max(0, (subscription.end_date - now).days)
|
||||
|
||||
discount_percent = settings.get_modem_period_discount(charged_months)
|
||||
if discount_percent > 0:
|
||||
discount_amount = base_price * discount_percent // 100
|
||||
final_price = base_price - discount_amount
|
||||
else:
|
||||
discount_amount = 0
|
||||
final_price = base_price
|
||||
|
||||
return ModemPriceResult(
|
||||
base_price=base_price,
|
||||
final_price=final_price,
|
||||
discount_percent=discount_percent,
|
||||
discount_amount=discount_amount,
|
||||
charged_months=charged_months,
|
||||
remaining_days=remaining_days,
|
||||
end_date=subscription.end_date
|
||||
)
|
||||
|
||||
def check_balance(self, user: User, price: int) -> Tuple[bool, int]:
|
||||
"""
|
||||
Проверяет достаточность баланса.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
price: Требуемая сумма
|
||||
|
||||
Returns:
|
||||
Tuple[достаточно ли средств, недостающая сумма]
|
||||
"""
|
||||
if price <= 0:
|
||||
return True, 0
|
||||
|
||||
if user.balance_kopeks >= price:
|
||||
return True, 0
|
||||
|
||||
missing = price - user.balance_kopeks
|
||||
return False, missing
|
||||
|
||||
async def enable_modem(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription
|
||||
) -> ModemEnableResult:
|
||||
"""
|
||||
Подключает модем к подписке.
|
||||
|
||||
Выполняет:
|
||||
1. Расчёт цены
|
||||
2. Проверку баланса
|
||||
3. Списание средств
|
||||
4. Создание транзакции
|
||||
5. Обновление подписки
|
||||
6. Синхронизацию с RemnaWave
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user: Пользователь
|
||||
subscription: Подписка
|
||||
|
||||
Returns:
|
||||
ModemEnableResult с результатом операции
|
||||
"""
|
||||
price_info = self.calculate_price(subscription)
|
||||
price = price_info.final_price
|
||||
|
||||
has_funds, _ = self.check_balance(user, price)
|
||||
if not has_funds:
|
||||
return ModemEnableResult(
|
||||
success=False,
|
||||
error=ModemError.INSUFFICIENT_FUNDS
|
||||
)
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(
|
||||
db, user, price,
|
||||
"Подключение модема"
|
||||
)
|
||||
|
||||
if not success:
|
||||
return ModemEnableResult(
|
||||
success=False,
|
||||
error=ModemError.CHARGE_ERROR
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f"Подключение модема на {price_info.charged_months} мес"
|
||||
)
|
||||
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
f"Пользователь {user.telegram_id} подключил модем, списано: {price / 100}₽"
|
||||
)
|
||||
|
||||
return ModemEnableResult(
|
||||
success=True,
|
||||
charged_amount=price,
|
||||
new_device_limit=subscription.device_limit
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка подключения модема для пользователя {user.telegram_id}: {e}")
|
||||
await db.rollback()
|
||||
return ModemEnableResult(
|
||||
success=False,
|
||||
error=ModemError.UPDATE_ERROR
|
||||
)
|
||||
|
||||
async def disable_modem(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription
|
||||
) -> ModemDisableResult:
|
||||
"""
|
||||
Отключает модем от подписки.
|
||||
|
||||
Возврат средств не производится.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user: Пользователь
|
||||
subscription: Подписка
|
||||
|
||||
Returns:
|
||||
ModemDisableResult с результатом операции
|
||||
"""
|
||||
try:
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"Пользователь {user.telegram_id} отключил модем")
|
||||
|
||||
return ModemDisableResult(
|
||||
success=True,
|
||||
new_device_limit=subscription.device_limit
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отключения модема для пользователя {user.telegram_id}: {e}")
|
||||
await db.rollback()
|
||||
return ModemDisableResult(
|
||||
success=False,
|
||||
error=ModemError.UPDATE_ERROR
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_period_warning_level(remaining_days: int) -> Optional[str]:
|
||||
"""
|
||||
Определяет уровень предупреждения о сроке действия.
|
||||
|
||||
Args:
|
||||
remaining_days: Оставшиеся дни подписки
|
||||
|
||||
Returns:
|
||||
"critical" если <= 7 дней
|
||||
"info" если <= 30 дней
|
||||
None если больше 30 дней
|
||||
"""
|
||||
if remaining_days <= MODEM_WARNING_DAYS_CRITICAL:
|
||||
return "critical"
|
||||
if remaining_days <= MODEM_WARNING_DAYS_INFO:
|
||||
return "info"
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance для использования в хендлерах
|
||||
_modem_service: Optional[ModemService] = None
|
||||
|
||||
|
||||
def get_modem_service() -> ModemService:
|
||||
"""Возвращает singleton экземпляр ModemService."""
|
||||
global _modem_service
|
||||
if _modem_service is None:
|
||||
_modem_service = ModemService()
|
||||
return _modem_service
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Фоновый сервис для обработки очереди чеков NaloGO.
|
||||
|
||||
При временной недоступности сервиса nalog.ru (503), чеки сохраняются в Redis
|
||||
и отправляются позже этим сервисом.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NalogoQueueService:
|
||||
"""Сервис фоновой обработки очереди чеков NaloGO."""
|
||||
|
||||
def __init__(self, nalogo_service: Optional[NaloGoService] = None):
|
||||
self._nalogo_service = nalogo_service
|
||||
self._bot: Optional[Bot] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._running = False
|
||||
self._last_notification_time: Optional[datetime] = None
|
||||
self._notification_cooldown = timedelta(hours=1) # Не чаще раза в час
|
||||
self._had_pending_receipts = False # Флаг для отслеживания успешной разгрузки
|
||||
|
||||
def set_nalogo_service(self, service: NaloGoService) -> None:
|
||||
"""Установить сервис NaloGO."""
|
||||
self._nalogo_service = service
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
"""Установить бота для отправки уведомлений."""
|
||||
self._bot = bot
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Проверка, запущен ли сервис."""
|
||||
return self._running and self._task is not None and not self._task.done()
|
||||
|
||||
@property
|
||||
def _check_interval(self) -> int:
|
||||
"""Интервал проверки очереди в секундах."""
|
||||
return getattr(settings, "NALOGO_QUEUE_CHECK_INTERVAL", 300)
|
||||
|
||||
@property
|
||||
def _receipt_delay(self) -> int:
|
||||
"""Задержка между отправкой чеков в секундах."""
|
||||
return getattr(settings, "NALOGO_QUEUE_RECEIPT_DELAY", 3)
|
||||
|
||||
@property
|
||||
def _max_attempts(self) -> int:
|
||||
"""Максимальное количество попыток отправки чека."""
|
||||
return getattr(settings, "NALOGO_QUEUE_MAX_ATTEMPTS", 10)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Запустить фоновую обработку очереди."""
|
||||
if not self._nalogo_service or not self._nalogo_service.configured:
|
||||
logger.info("NaloGO не настроен, сервис очереди чеков не запущен")
|
||||
return
|
||||
|
||||
if self.is_running():
|
||||
logger.warning("Сервис очереди чеков уже запущен")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._process_queue_loop())
|
||||
logger.info(
|
||||
f"Сервис очереди чеков NaloGO запущен "
|
||||
f"(интервал: {self._check_interval}с, задержка между чеками: {self._receipt_delay}с)"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Остановить фоновую обработку."""
|
||||
self._running = False
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
logger.info("Сервис очереди чеков NaloGO остановлен")
|
||||
|
||||
async def _send_admin_notification(self, message: str, skip_cooldown: bool = False) -> None:
|
||||
"""Отправить уведомление админам о чеках."""
|
||||
if not self._bot:
|
||||
return
|
||||
|
||||
chat_id = settings.get_admin_notifications_chat_id()
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
topic_id = settings.ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
|
||||
|
||||
# Проверяем cooldown (можно пропустить для важных уведомлений)
|
||||
if not skip_cooldown:
|
||||
now = datetime.now()
|
||||
if self._last_notification_time:
|
||||
if now - self._last_notification_time < self._notification_cooldown:
|
||||
logger.debug("Уведомление о чеках пропущено (cooldown)")
|
||||
return
|
||||
|
||||
try:
|
||||
await self._bot.send_message(
|
||||
chat_id=chat_id,
|
||||
message_thread_id=topic_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
self._last_notification_time = datetime.now()
|
||||
logger.info("Отправлено уведомление о чеках NaloGO")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка отправки уведомления о чеках: {error}")
|
||||
|
||||
async def _process_queue_loop(self) -> None:
|
||||
"""Основной цикл обработки очереди."""
|
||||
while self._running:
|
||||
try:
|
||||
await self._process_pending_receipts()
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в цикле обработки очереди чеков: {error}")
|
||||
|
||||
await asyncio.sleep(self._check_interval)
|
||||
|
||||
async def _process_pending_receipts(self) -> None:
|
||||
"""Обработать все ожидающие чеки в очереди."""
|
||||
if not self._nalogo_service:
|
||||
return
|
||||
|
||||
queue_length = await self._nalogo_service.get_queue_length()
|
||||
if queue_length == 0:
|
||||
return
|
||||
|
||||
logger.info(f"Начинаем обработку очереди чеков: {queue_length} шт.")
|
||||
self._had_pending_receipts = True
|
||||
|
||||
processed = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
total_processed_amount = 0.0
|
||||
service_unavailable = False
|
||||
|
||||
while True:
|
||||
receipt_data = await self._nalogo_service.pop_receipt_from_queue()
|
||||
if not receipt_data:
|
||||
break
|
||||
|
||||
attempts = receipt_data.get("attempts", 0)
|
||||
payment_id = receipt_data.get("payment_id", "unknown")
|
||||
amount = receipt_data.get("amount", 0)
|
||||
|
||||
# Проверяем количество попыток
|
||||
if attempts >= self._max_attempts:
|
||||
logger.error(
|
||||
f"Чек {payment_id} превысил лимит попыток ({self._max_attempts}), "
|
||||
f"удален из очереди"
|
||||
)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Пытаемся отправить чек
|
||||
try:
|
||||
receipt_uuid = await self._nalogo_service.create_receipt(
|
||||
name=receipt_data.get("name", "Интернет-сервис - Пополнение баланса"),
|
||||
amount=amount,
|
||||
quantity=receipt_data.get("quantity", 1),
|
||||
client_info=receipt_data.get("client_info"),
|
||||
payment_id=payment_id,
|
||||
queue_on_failure=False, # Не добавлять в очередь повторно автоматически
|
||||
)
|
||||
|
||||
if receipt_uuid:
|
||||
processed += 1
|
||||
total_processed_amount += amount
|
||||
logger.info(
|
||||
f"Чек из очереди успешно создан: {receipt_uuid} "
|
||||
f"(payment_id={payment_id}, попытка {attempts + 1})"
|
||||
)
|
||||
else:
|
||||
# Вернуть в очередь с увеличенным счетчиком попыток
|
||||
await self._nalogo_service.requeue_receipt(receipt_data)
|
||||
failed += 1
|
||||
service_unavailable = True
|
||||
logger.warning(
|
||||
f"Не удалось создать чек из очереди (payment_id={payment_id}), "
|
||||
f"возвращен в очередь (попытка {attempts + 1}/{self._max_attempts})"
|
||||
)
|
||||
# Если сервис недоступен, прекращаем попытки до следующего цикла
|
||||
break
|
||||
|
||||
except Exception as error:
|
||||
await self._nalogo_service.requeue_receipt(receipt_data)
|
||||
failed += 1
|
||||
logger.error(
|
||||
f"Ошибка при создании чека из очереди (payment_id={payment_id}): {error}"
|
||||
)
|
||||
# Прекращаем попытки при ошибке
|
||||
break
|
||||
|
||||
# Задержка между чеками чтобы не долбить API
|
||||
await asyncio.sleep(self._receipt_delay)
|
||||
|
||||
if processed > 0 or failed > 0 or skipped > 0:
|
||||
logger.info(
|
||||
f"Обработка очереди завершена: "
|
||||
f"успешно={processed}, неудачно={failed}, пропущено={skipped}"
|
||||
)
|
||||
|
||||
# Проверяем остаток в очереди
|
||||
remaining = await self._nalogo_service.get_queue_length()
|
||||
|
||||
# Отправляем уведомление если есть проблемы
|
||||
if service_unavailable or failed > 0:
|
||||
if remaining > 0:
|
||||
queued = await self._nalogo_service.get_queued_receipts()
|
||||
total_queued_amount = sum(r.get("amount", 0) for r in queued)
|
||||
|
||||
message = (
|
||||
f"<b>⚠️ Проблема с отправкой чеков NaloGO</b>\n\n"
|
||||
f"Сервис nalog.ru временно недоступен.\n\n"
|
||||
f"📋 <b>В очереди:</b> {remaining} чек(ов)\n"
|
||||
f"💰 <b>На сумму:</b> {total_queued_amount:,.2f} ₽\n\n"
|
||||
f"Чеки будут отправлены автоматически когда сервис восстановится."
|
||||
)
|
||||
await self._send_admin_notification(message)
|
||||
|
||||
# Уведомление об успешной разгрузке очереди
|
||||
elif remaining == 0 and self._had_pending_receipts and processed > 0:
|
||||
self._had_pending_receipts = False
|
||||
message = (
|
||||
f"<b>✅ Очередь чеков NaloGO разгружена</b>\n\n"
|
||||
f"Все отложенные чеки успешно отправлены!\n\n"
|
||||
f"📋 <b>Отправлено:</b> {processed} чек(ов)\n"
|
||||
f"💰 <b>На сумму:</b> {total_processed_amount:,.2f} ₽"
|
||||
)
|
||||
await self._send_admin_notification(message, skip_cooldown=True)
|
||||
|
||||
async def force_process(self) -> dict:
|
||||
"""Принудительно обработать очередь (для ручного запуска)."""
|
||||
if not self._nalogo_service:
|
||||
return {"error": "NaloGO сервис не настроен"}
|
||||
|
||||
queue_length = await self._nalogo_service.get_queue_length()
|
||||
if queue_length == 0:
|
||||
return {"message": "Очередь пуста", "processed": 0}
|
||||
|
||||
await self._process_pending_receipts()
|
||||
new_length = await self._nalogo_service.get_queue_length()
|
||||
|
||||
return {
|
||||
"message": "Обработка завершена",
|
||||
"was_in_queue": queue_length,
|
||||
"remaining": new_length,
|
||||
"processed": queue_length - new_length,
|
||||
}
|
||||
|
||||
async def get_status(self) -> dict:
|
||||
"""Получить статус сервиса и очереди."""
|
||||
queue_length = 0
|
||||
total_amount = 0.0
|
||||
queued_receipts = []
|
||||
|
||||
if self._nalogo_service:
|
||||
queue_length = await self._nalogo_service.get_queue_length()
|
||||
if queue_length > 0:
|
||||
queued_receipts = await self._nalogo_service.get_queued_receipts()
|
||||
total_amount = sum(r.get("amount", 0) for r in queued_receipts)
|
||||
|
||||
return {
|
||||
"running": self.is_running(),
|
||||
"check_interval_seconds": self._check_interval,
|
||||
"receipt_delay_seconds": self._receipt_delay,
|
||||
"queue_length": queue_length,
|
||||
"total_amount": total_amount,
|
||||
"max_attempts": self._max_attempts,
|
||||
"queued_receipts": queued_receipts[:10], # Показываем только первые 10
|
||||
}
|
||||
|
||||
|
||||
# Глобальный экземпляр сервиса
|
||||
nalogo_queue_service = NalogoQueueService()
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -6,9 +7,12 @@ from nalogo import Client
|
||||
from nalogo.dto.income import IncomeClient, IncomeType
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NALOGO_QUEUE_KEY = "nalogo:receipt_queue"
|
||||
|
||||
|
||||
class NaloGoService:
|
||||
"""Сервис для работы с API NaloGO (налоговая служба самозанятых)."""
|
||||
@@ -49,6 +53,53 @@ class NaloGoService:
|
||||
)
|
||||
self.configured = False
|
||||
|
||||
@staticmethod
|
||||
def _is_service_unavailable(error: Exception) -> bool:
|
||||
"""Проверяет, является ли ошибка временной недоступностью сервиса."""
|
||||
error_str = str(error).lower()
|
||||
error_type = type(error).__name__.lower()
|
||||
return (
|
||||
"503" in error_str
|
||||
or "service temporarily unavailable" in error_str
|
||||
or "service unavailable" in error_str
|
||||
or "ведутся работы" in error_str
|
||||
or ("health" in error_str and "false" in error_str)
|
||||
# Таймауты и сетевые ошибки — временные проблемы
|
||||
or "timeout" in error_type
|
||||
or "timeout" in error_str
|
||||
or "readtimeout" in error_type
|
||||
or "connecttimeout" in error_type
|
||||
or "connectionerror" in error_type
|
||||
or "connecterror" in error_type
|
||||
)
|
||||
|
||||
async def _queue_receipt(
|
||||
self,
|
||||
name: str,
|
||||
amount: float,
|
||||
quantity: int,
|
||||
client_info: Optional[Dict[str, Any]],
|
||||
payment_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Добавить чек в очередь для отложенной отправки."""
|
||||
receipt_data = {
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"quantity": quantity,
|
||||
"client_info": client_info,
|
||||
"payment_id": payment_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"attempts": 0,
|
||||
}
|
||||
success = await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
|
||||
if success:
|
||||
queue_len = await cache.llen(NALOGO_QUEUE_KEY)
|
||||
logger.info(
|
||||
f"Чек добавлен в очередь (payment_id={payment_id}, "
|
||||
f"сумма={amount}₽, в очереди: {queue_len})"
|
||||
)
|
||||
return success
|
||||
|
||||
async def authenticate(self) -> bool:
|
||||
"""Аутентификация в сервисе NaloGO."""
|
||||
if not self.configured:
|
||||
@@ -60,10 +111,24 @@ class NaloGoService:
|
||||
logger.info("Успешная аутентификация в NaloGO")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
|
||||
if self._is_service_unavailable(error):
|
||||
logger.warning(
|
||||
"NaloGO временно недоступен (техработы): %s",
|
||||
str(error)[:200]
|
||||
)
|
||||
else:
|
||||
logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
async def create_receipt(self, name: str, amount: float, quantity: int = 1, client_info: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
async def create_receipt(
|
||||
self,
|
||||
name: str,
|
||||
amount: float,
|
||||
quantity: int = 1,
|
||||
client_info: Optional[Dict[str, Any]] = None,
|
||||
payment_id: Optional[str] = None,
|
||||
queue_on_failure: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Создание чека о доходе.
|
||||
|
||||
Args:
|
||||
@@ -71,6 +136,8 @@ class NaloGoService:
|
||||
amount: Сумма в рублях
|
||||
quantity: Количество
|
||||
client_info: Информация о клиенте (опционально)
|
||||
payment_id: ID платежа для логирования
|
||||
queue_on_failure: Добавить в очередь при временной недоступности
|
||||
|
||||
Returns:
|
||||
UUID чека или None при ошибке
|
||||
@@ -84,6 +151,9 @@ class NaloGoService:
|
||||
if not hasattr(self.client, '_access_token') or not self.client._access_token:
|
||||
auth_success = await self.authenticate()
|
||||
if not auth_success:
|
||||
# Если сервис недоступен — добавляем в очередь
|
||||
if queue_on_failure:
|
||||
await self._queue_receipt(name, amount, quantity, client_info, payment_id)
|
||||
return None
|
||||
|
||||
income_api = self.client.income()
|
||||
@@ -114,5 +184,30 @@ class NaloGoService:
|
||||
return None
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
|
||||
if self._is_service_unavailable(error):
|
||||
logger.warning(
|
||||
"NaloGO временно недоступен, чек будет отправлен позже "
|
||||
f"(payment_id={payment_id}, сумма={amount}₽)"
|
||||
)
|
||||
if queue_on_failure:
|
||||
await self._queue_receipt(name, amount, quantity, client_info, payment_id)
|
||||
else:
|
||||
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_queue_length(self) -> int:
|
||||
"""Получить количество чеков в очереди."""
|
||||
return await cache.llen(NALOGO_QUEUE_KEY)
|
||||
|
||||
async def get_queued_receipts(self) -> list:
|
||||
"""Получить список чеков в очереди (без удаления)."""
|
||||
return await cache.lrange(NALOGO_QUEUE_KEY)
|
||||
|
||||
async def pop_receipt_from_queue(self) -> Optional[Dict[str, Any]]:
|
||||
"""Извлечь следующий чек из очереди."""
|
||||
return await cache.rpop(NALOGO_QUEUE_KEY)
|
||||
|
||||
async def requeue_receipt(self, receipt_data: Dict[str, Any]) -> bool:
|
||||
"""Вернуть чек обратно в очередь (при неудачной отправке)."""
|
||||
receipt_data["attempts"] = receipt_data.get("attempts", 0) + 1
|
||||
return await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.cloudpayments_service import CloudPaymentsAPIError, CloudPaymentsService
|
||||
@@ -259,11 +260,19 @@ class CloudPaymentsPaymentMixin:
|
||||
logger.exception("Ошибка отправки уведомления CloudPayments: %s", error)
|
||||
|
||||
# Auto-purchase if enabled
|
||||
auto_purchase_success = False
|
||||
try:
|
||||
await auto_purchase_saved_cart_after_topup(db, user)
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка автопокупки после CloudPayments: %s", error)
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error)
|
||||
|
||||
return True
|
||||
|
||||
async def process_cloudpayments_fail_webhook(
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.subscription_renewal_service import (
|
||||
@@ -376,6 +377,22 @@ class CryptoBotPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=bot_instance,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and bot_instance:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
@@ -396,11 +397,23 @@ class MulenPayPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.pal24_service import Pal24APIError
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
@@ -499,6 +500,18 @@ class Pal24PaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
@@ -485,6 +486,18 @@ class PlategaPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.external.telegram_stars import TelegramStarsService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
@@ -534,6 +535,22 @@ class TelegramStarsMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.wata_service import WataAPIError, WataService
|
||||
@@ -569,6 +570,18 @@ class WataPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
@@ -741,6 +742,22 @@ class YooKassaPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, "bot", None),
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
@@ -1029,13 +1046,13 @@ class YooKassaPaymentMixin:
|
||||
receipt_uuid = await self.nalogo_service.create_receipt(
|
||||
name=receipt_name,
|
||||
amount=amount_rubles,
|
||||
quantity=1
|
||||
quantity=1,
|
||||
payment_id=payment.yookassa_payment_id,
|
||||
)
|
||||
|
||||
if receipt_uuid:
|
||||
logger.info(f"Чек NaloGO создан для платежа {payment.yookassa_payment_id}: {receipt_uuid}")
|
||||
else:
|
||||
logger.warning(f"Не удалось создать чек NaloGO для платежа {payment.yookassa_payment_id}")
|
||||
# При временной недоступности чек добавляется в очередь автоматически
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.config import settings
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import (
|
||||
PaymentMethod,
|
||||
Subscription,
|
||||
SubscriptionConversion,
|
||||
SubscriptionStatus,
|
||||
@@ -499,6 +500,11 @@ class ReportingService:
|
||||
Transaction.created_at >= start_utc,
|
||||
Transaction.created_at < end_utc,
|
||||
self._exclude_referral_deposits_condition(),
|
||||
# Исключаем ручные (админские) пополнения из статистики
|
||||
or_(
|
||||
Transaction.payment_method.is_(None),
|
||||
Transaction.payment_method != PaymentMethod.MANUAL.value,
|
||||
),
|
||||
)
|
||||
|
||||
async def _get_top_referrers(
|
||||
|
||||
@@ -624,4 +624,282 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["auto_purchase_saved_cart_after_topup"]
|
||||
async def auto_activate_subscription_after_topup(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
*,
|
||||
bot: Optional[Bot] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Умная автоактивация после пополнения баланса.
|
||||
|
||||
Работает БЕЗ сохранённой корзины:
|
||||
- Если подписка активна — ничего не делает
|
||||
- Если подписка истекла — продлевает с теми же параметрами
|
||||
- Если подписки нет — создаёт новую с дефолтными параметрами
|
||||
|
||||
Выбирает максимальный период, который можно оплатить из баланса.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.subscription_renewal_service import SubscriptionRenewalService
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if not settings.is_auto_activate_after_topup_enabled():
|
||||
return False
|
||||
|
||||
if not user or not getattr(user, "id", None):
|
||||
return False
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Если подписка активна — ничего не делаем
|
||||
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
|
||||
logger.info(
|
||||
"🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем",
|
||||
user.telegram_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# Определяем параметры подписки
|
||||
if subscription:
|
||||
device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||||
connected_squads = subscription.connected_squads or []
|
||||
else:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
traffic_limit_gb = 0
|
||||
connected_squads = []
|
||||
|
||||
# Если серверы не выбраны — берём бесплатные по умолчанию
|
||||
if not connected_squads:
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=user.promo_group_id)
|
||||
connected_squads = [
|
||||
s.squad_uuid for s in available_servers
|
||||
if s.is_available and s.price_kopeks == 0
|
||||
]
|
||||
if not connected_squads and available_servers:
|
||||
connected_squads = [available_servers[0].squad_uuid]
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||||
|
||||
balance = user.balance_kopeks
|
||||
available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
|
||||
|
||||
if not available_periods:
|
||||
logger.warning("🔁 Автоактивация: нет доступных периодов подписки")
|
||||
return False
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# Найти максимальный период <= баланса
|
||||
best_period = None
|
||||
best_price = 0
|
||||
|
||||
for period in available_periods:
|
||||
try:
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period,
|
||||
traffic_limit_gb,
|
||||
server_ids,
|
||||
device_limit,
|
||||
db,
|
||||
user=user
|
||||
)
|
||||
if price <= balance:
|
||||
best_period = period
|
||||
best_price = price
|
||||
break
|
||||
except Exception as calc_error:
|
||||
logger.warning(
|
||||
"🔁 Автоактивация: ошибка расчёта цены для периода %s: %s",
|
||||
period,
|
||||
calc_error,
|
||||
)
|
||||
continue
|
||||
|
||||
if not best_period:
|
||||
logger.info(
|
||||
"🔁 Автоактивация: у пользователя %s недостаточно средств (%s) для любого периода",
|
||||
user.telegram_id,
|
||||
balance,
|
||||
)
|
||||
return False
|
||||
|
||||
texts = get_texts(getattr(user, "language", "ru"))
|
||||
|
||||
try:
|
||||
if subscription:
|
||||
# Продление существующей подписки
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
pricing = await renewal_service.calculate_pricing(
|
||||
db, user, subscription, best_period
|
||||
)
|
||||
|
||||
old_end_date = subscription.end_date
|
||||
result = await renewal_service.finalize(
|
||||
db, user, subscription,
|
||||
pricing,
|
||||
description=f"Автоматическое продление на {best_period} дней",
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Автоактивация: подписка пользователя %s продлена на %s дней за %s коп.",
|
||||
user.telegram_id,
|
||||
best_period,
|
||||
best_price,
|
||||
)
|
||||
|
||||
# Уведомление пользователю
|
||||
if bot:
|
||||
try:
|
||||
period_label = format_period_description(best_period, getattr(user, "language", "ru"))
|
||||
new_end_date = result.subscription.end_date
|
||||
end_date_str = new_end_date.strftime("%d.%m.%Y") if new_end_date else "—"
|
||||
|
||||
message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED",
|
||||
"✅ Подписка автоматически продлена на {period}.",
|
||||
).format(period=period_label)
|
||||
|
||||
details = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS",
|
||||
"⏰ Новая дата окончания: {date}.",
|
||||
).format(date=end_date_str)
|
||||
|
||||
hint = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
|
||||
"Перейдите в раздел «Моя подписка», чтобы получить ссылку.",
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription",
|
||||
)],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"{message}\n{details}\n\n{hint}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as notify_error:
|
||||
logger.warning(
|
||||
"⚠️ Автоактивация: не удалось уведомить пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
else:
|
||||
# Создание новой подписки
|
||||
new_subscription = await create_paid_subscription(
|
||||
db,
|
||||
user.id,
|
||||
best_period,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=True
|
||||
)
|
||||
|
||||
await subtract_user_balance(
|
||||
db, user, best_price,
|
||||
f"Активация подписки на {best_period} дней"
|
||||
)
|
||||
|
||||
await subscription_service.create_remnawave_user(db, new_subscription)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=best_price,
|
||||
description=f"Активация подписки на {best_period} дней",
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Автоактивация: новая подписка на %s дней создана для пользователя %s за %s коп.",
|
||||
best_period,
|
||||
user.telegram_id,
|
||||
best_price,
|
||||
)
|
||||
|
||||
# Уведомление пользователю
|
||||
if bot:
|
||||
try:
|
||||
period_label = format_period_description(best_period, getattr(user, "language", "ru"))
|
||||
|
||||
message = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS",
|
||||
"✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
|
||||
).format(period=period_label)
|
||||
|
||||
hint = texts.t(
|
||||
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
|
||||
"Перейдите в раздел «Моя подписка», чтобы получить ссылку.",
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
|
||||
callback_data="menu_subscription",
|
||||
)],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f"{message}\n\n{hint}",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
# Уведомление админам
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_subscription_purchase_notification(
|
||||
db,
|
||||
user,
|
||||
new_subscription,
|
||||
None, # transaction
|
||||
best_period,
|
||||
False, # was_trial_conversion
|
||||
)
|
||||
except Exception as admin_error:
|
||||
logger.warning(
|
||||
"⚠️ Автоактивация: не удалось уведомить админов: %s",
|
||||
admin_error,
|
||||
)
|
||||
|
||||
except Exception as notify_error:
|
||||
logger.warning(
|
||||
"⚠️ Автоактивация: не удалось уведомить пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"❌ Автоактивация: ошибка для пользователя %s: %s",
|
||||
user.telegram_id,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["auto_purchase_saved_cart_after_topup", "auto_activate_subscription_after_topup"]
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.external.tribute import TributeService as TributeAPI
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
@@ -314,6 +315,18 @@ class TributeService:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(session, user)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and self.bot:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
|
||||
@@ -395,7 +395,11 @@ class UserService:
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
if amount_kopeks > 0:
|
||||
await add_user_balance(db, user, amount_kopeks, description=description)
|
||||
await add_user_balance(
|
||||
db, user, amount_kopeks,
|
||||
description=description,
|
||||
payment_method=PaymentMethod.MANUAL
|
||||
)
|
||||
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽")
|
||||
success = True
|
||||
else:
|
||||
|
||||
+51
-1
@@ -159,7 +159,7 @@ class CacheService:
|
||||
async def get_hash(self, name: str, key: str = None) -> Optional[Union[dict, str]]:
|
||||
if not self._connected:
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
if key:
|
||||
value = await self.redis_client.hget(name, key)
|
||||
@@ -171,6 +171,56 @@ class CacheService:
|
||||
logger.error(f"Ошибка получения хеша {name}: {e}")
|
||||
return None
|
||||
|
||||
async def lpush(self, key: str, value: Any) -> bool:
|
||||
"""Добавить элемент в начало списка (очереди)."""
|
||||
if not self._connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
serialized = json.dumps(value, default=str)
|
||||
await self.redis_client.lpush(key, serialized)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления в очередь {key}: {e}")
|
||||
return False
|
||||
|
||||
async def rpop(self, key: str) -> Optional[Any]:
|
||||
"""Извлечь элемент из конца списка (FIFO очередь)."""
|
||||
if not self._connected:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = await self.redis_client.rpop(key)
|
||||
if value:
|
||||
return json.loads(value)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка извлечения из очереди {key}: {e}")
|
||||
return None
|
||||
|
||||
async def llen(self, key: str) -> int:
|
||||
"""Получить длину списка (очереди)."""
|
||||
if not self._connected:
|
||||
return 0
|
||||
|
||||
try:
|
||||
return await self.redis_client.llen(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения длины очереди {key}: {e}")
|
||||
return 0
|
||||
|
||||
async def lrange(self, key: str, start: int = 0, end: int = -1) -> list:
|
||||
"""Получить элементы списка без удаления."""
|
||||
if not self._connected:
|
||||
return []
|
||||
|
||||
try:
|
||||
items = await self.redis_client.lrange(key, start, end)
|
||||
return [json.loads(item) for item in items]
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка чтения очереди {key}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
cache = CacheService()
|
||||
|
||||
|
||||
+94
-3
@@ -173,7 +173,7 @@ def typing_action(func: Callable) -> Callable:
|
||||
|
||||
def rate_limit(rate: float = 1.0, key: str = None):
|
||||
def decorator(func: Callable) -> Callable:
|
||||
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
event: types.Update,
|
||||
@@ -181,7 +181,98 @@ def rate_limit(rate: float = 1.0, key: str = None):
|
||||
**kwargs
|
||||
) -> Any:
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def modem_available(for_enable: bool = False, for_disable: bool = False):
|
||||
"""
|
||||
Декоратор для проверки доступности модема.
|
||||
|
||||
Проверяет:
|
||||
- Наличие подписки
|
||||
- Подписка не триальная
|
||||
- Функция модема включена в настройках
|
||||
- (опционально) Модем ещё не подключен (for_enable=True)
|
||||
- (опционально) Модем уже подключен (for_disable=True)
|
||||
|
||||
Args:
|
||||
for_enable: Проверять, что модем ещё не подключен
|
||||
for_disable: Проверять, что модем подключен
|
||||
|
||||
Usage:
|
||||
@modem_available()
|
||||
async def handle_modem_menu(callback, db_user, db): ...
|
||||
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_enable(callback, db_user, db): ...
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
event: types.Update,
|
||||
*args,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
db_user = kwargs.get('db_user')
|
||||
|
||||
if not db_user:
|
||||
logger.warning("modem_available: нет db_user в kwargs")
|
||||
return
|
||||
|
||||
from app.services.modem_service import get_modem_service, ModemError
|
||||
|
||||
service = get_modem_service()
|
||||
result = service.check_availability(
|
||||
db_user,
|
||||
for_enable=for_enable,
|
||||
for_disable=for_disable
|
||||
)
|
||||
|
||||
if not result.available:
|
||||
texts = get_texts(db_user.language if db_user else 'ru')
|
||||
|
||||
error_messages = {
|
||||
ModemError.NO_SUBSCRIPTION: texts.t(
|
||||
"MODEM_PAID_ONLY",
|
||||
"Модем доступен только для платных подписок"
|
||||
),
|
||||
ModemError.TRIAL_SUBSCRIPTION: texts.t(
|
||||
"MODEM_PAID_ONLY",
|
||||
"Модем доступен только для платных подписок"
|
||||
),
|
||||
ModemError.MODEM_DISABLED: texts.t(
|
||||
"MODEM_DISABLED",
|
||||
"Функция модема отключена"
|
||||
),
|
||||
ModemError.ALREADY_ENABLED: texts.t(
|
||||
"MODEM_ALREADY_ENABLED",
|
||||
"Модем уже подключен"
|
||||
),
|
||||
ModemError.NOT_ENABLED: texts.t(
|
||||
"MODEM_NOT_ENABLED",
|
||||
"Модем не подключен"
|
||||
),
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result.error, texts.ERROR)
|
||||
|
||||
try:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer(error_text, show_alert=True)
|
||||
elif isinstance(event, types.Message):
|
||||
await event.answer(error_text)
|
||||
except TelegramBadRequest as e:
|
||||
if "query is too old" not in str(e).lower():
|
||||
raise
|
||||
|
||||
return None
|
||||
|
||||
return await func(event, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -82,7 +82,8 @@ def _serialize_template(tpl: ContestTemplate) -> ContestTemplateResponse:
|
||||
name=tpl.name,
|
||||
slug=tpl.slug,
|
||||
description=tpl.description,
|
||||
prize_days=tpl.prize_days,
|
||||
prize_type=tpl.prize_type,
|
||||
prize_value=tpl.prize_value,
|
||||
max_winners=tpl.max_winners,
|
||||
attempts_per_user=tpl.attempts_per_user,
|
||||
times_per_day=tpl.times_per_day,
|
||||
|
||||
@@ -30,6 +30,7 @@ from ..schemas.subscriptions import (
|
||||
SubscriptionCreateRequest,
|
||||
SubscriptionDevicesRequest,
|
||||
SubscriptionExtendRequest,
|
||||
SubscriptionModemRequest,
|
||||
SubscriptionResponse,
|
||||
SubscriptionSquadRequest,
|
||||
SubscriptionTrafficRequest,
|
||||
@@ -52,6 +53,7 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
@@ -302,3 +304,41 @@ async def remove_subscription_squad_endpoint(
|
||||
subscription = await remove_subscription_squad(db, subscription, squad_uuid)
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@router.post("/{subscription_id}/modem", response_model=SubscriptionResponse)
|
||||
async def set_subscription_modem(
|
||||
subscription_id: int,
|
||||
payload: SubscriptionModemRequest,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubscriptionResponse:
|
||||
"""Включить или выключить модем для подписки."""
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
|
||||
if subscription.is_trial:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Modem is not available for trial subscriptions")
|
||||
|
||||
if not settings.is_modem_enabled():
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Modem feature is disabled")
|
||||
|
||||
current_modem = getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
if payload.enabled == current_modem:
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
if payload.enabled:
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
else:
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
@@ -59,6 +59,7 @@ def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[Su
|
||||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||||
traffic_used_gb=subscription.traffic_used_gb,
|
||||
device_limit=subscription.device_limit,
|
||||
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
|
||||
autopay_enabled=subscription.autopay_enabled,
|
||||
autopay_days_before=subscription.autopay_days_before,
|
||||
subscription_url=subscription.subscription_url,
|
||||
|
||||
@@ -11,7 +11,8 @@ class ContestTemplateResponse(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
prize_days: int
|
||||
prize_type: str
|
||||
prize_value: str
|
||||
max_winners: int
|
||||
attempts_per_user: int
|
||||
times_per_day: int
|
||||
@@ -30,7 +31,8 @@ class ContestTemplateListResponse(BaseModel):
|
||||
class ContestTemplateUpdateRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
prize_days: Optional[int] = Field(None, ge=1)
|
||||
prize_type: Optional[str] = None
|
||||
prize_value: Optional[str] = None
|
||||
max_winners: Optional[int] = Field(None, ge=1)
|
||||
attempts_per_user: Optional[int] = Field(None, ge=1)
|
||||
times_per_day: Optional[int] = Field(None, ge=1)
|
||||
|
||||
@@ -17,6 +17,7 @@ class SubscriptionResponse(BaseModel):
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
modem_enabled: bool = False
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: Optional[int] = None
|
||||
subscription_url: Optional[str] = None
|
||||
@@ -51,3 +52,7 @@ class SubscriptionDevicesRequest(BaseModel):
|
||||
|
||||
class SubscriptionSquadRequest(BaseModel):
|
||||
squad_uuid: str
|
||||
|
||||
|
||||
class SubscriptionModemRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
@@ -25,6 +25,7 @@ class SubscriptionSummary(BaseModel):
|
||||
traffic_limit_gb: int
|
||||
traffic_used_gb: float
|
||||
device_limit: int
|
||||
modem_enabled: bool = False
|
||||
autopay_enabled: bool
|
||||
autopay_days_before: Optional[int] = None
|
||||
subscription_url: Optional[str] = None
|
||||
|
||||
@@ -34,6 +34,7 @@ from app.services.external_admin_service import ensure_external_admin_token
|
||||
from app.services.broadcast_service import broadcast_service
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
from app.services.contest_rotation_service import contest_rotation_service
|
||||
from app.services.nalogo_queue_service import nalogo_queue_service
|
||||
from app.utils.startup_timeline import StartupTimeline
|
||||
from app.utils.timezone import TimezoneAwareFormatter
|
||||
|
||||
@@ -271,6 +272,11 @@ async def main():
|
||||
payment_service = PaymentService(bot)
|
||||
auto_payment_verification_service.set_payment_service(payment_service)
|
||||
|
||||
# Настройка сервиса очереди чеков NaloGO
|
||||
if payment_service.nalogo_service:
|
||||
nalogo_queue_service.set_nalogo_service(payment_service.nalogo_service)
|
||||
nalogo_queue_service.set_bot(bot)
|
||||
|
||||
verification_providers: list[str] = []
|
||||
auto_verification_active = False
|
||||
async with timeline.stage(
|
||||
@@ -331,6 +337,27 @@ async def main():
|
||||
if auto_verification_active:
|
||||
stage.log("Фоновая автопроверка запущена")
|
||||
|
||||
async with timeline.stage(
|
||||
"Очередь чеков NaloGO",
|
||||
"🧾",
|
||||
success_message="Сервис очереди чеков запущен",
|
||||
) as stage:
|
||||
if settings.is_nalogo_enabled():
|
||||
try:
|
||||
await nalogo_queue_service.start()
|
||||
if nalogo_queue_service.is_running():
|
||||
queue_len = await payment_service.nalogo_service.get_queue_length()
|
||||
if queue_len > 0:
|
||||
stage.log(f"В очереди ожидает {queue_len} чек(ов)")
|
||||
stage.success("Фоновая обработка чеков активна")
|
||||
else:
|
||||
stage.skip("Сервис не запущен")
|
||||
except Exception as e:
|
||||
stage.warning(f"Ошибка запуска очереди чеков: {e}")
|
||||
logger.error(f"❌ Ошибка запуска очереди чеков NaloGO: {e}")
|
||||
else:
|
||||
stage.skip("NaloGO отключен настройками")
|
||||
|
||||
async with timeline.stage(
|
||||
"Внешняя админка",
|
||||
"🛡️",
|
||||
@@ -646,6 +673,12 @@ async def main():
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки ротации игр: {e}")
|
||||
|
||||
logger.info("ℹ️ Остановка очереди чеков NaloGO...")
|
||||
try:
|
||||
await nalogo_queue_service.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки очереди чеков NaloGO: {e}")
|
||||
|
||||
logger.info("ℹ️ Остановка сервиса бекапов...")
|
||||
try:
|
||||
await backup_service.stop_auto_backup()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""add purchased_traffic_gb to subscriptions
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: f4a5b6c7d8e9
|
||||
Create Date: 2024-12-25 14:30:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a1b2c3d4e5f6'
|
||||
down_revision = 'f4a5b6c7d8e9'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Добавляем колонку purchased_traffic_gb для отслеживания докупленного трафика
|
||||
op.add_column('subscriptions', sa.Column('purchased_traffic_gb', sa.Integer(), nullable=True, server_default='0'))
|
||||
|
||||
# Устанавливаем NOT NULL после добавления значения по умолчанию
|
||||
op.alter_column('subscriptions', 'purchased_traffic_gb', nullable=False, server_default=None)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('subscriptions', 'purchased_traffic_gb')
|
||||
@@ -0,0 +1,19 @@
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f4a5b6c7d8e9"
|
||||
down_revision: Union[str, None] = "e3c1e0b5b4a7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("subscriptions", sa.Column("modem_enabled", sa.Boolean(), nullable=True, server_default="false"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("subscriptions", "modem_enabled")
|
||||
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
Тесты для ModemService - управление модемом в подписке.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services.modem_service import (
|
||||
ModemService,
|
||||
ModemError,
|
||||
ModemAvailabilityResult,
|
||||
ModemPriceResult,
|
||||
ModemEnableResult,
|
||||
ModemDisableResult,
|
||||
get_modem_service,
|
||||
MODEM_WARNING_DAYS_CRITICAL,
|
||||
MODEM_WARNING_DAYS_INFO,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_settings():
|
||||
"""Создаёт мок настроек приложения."""
|
||||
settings = MagicMock()
|
||||
settings.is_modem_enabled.return_value = True
|
||||
settings.get_modem_price_per_month.return_value = 10000 # 100 рублей
|
||||
settings.get_modem_period_discount.return_value = 0
|
||||
return settings
|
||||
|
||||
|
||||
def create_sample_user():
|
||||
"""Создаёт пример пользователя."""
|
||||
user = SimpleNamespace(
|
||||
id=1,
|
||||
telegram_id=123456789,
|
||||
balance_kopeks=50000, # 500 рублей
|
||||
language="ru",
|
||||
subscription=None,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def create_sample_subscription():
|
||||
"""Создаёт пример подписки."""
|
||||
subscription = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=1,
|
||||
is_trial=False,
|
||||
modem_enabled=False,
|
||||
device_limit=2,
|
||||
end_date=datetime.utcnow() + timedelta(days=30),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
return subscription
|
||||
|
||||
|
||||
def create_trial_subscription():
|
||||
"""Создаёт триальную подписку."""
|
||||
subscription = SimpleNamespace(
|
||||
id=2,
|
||||
user_id=1,
|
||||
is_trial=True,
|
||||
modem_enabled=False,
|
||||
device_limit=1,
|
||||
end_date=datetime.utcnow() + timedelta(days=7),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
return subscription
|
||||
|
||||
|
||||
def create_modem_service(monkeypatch):
|
||||
"""Создаёт ModemService с замоканными настройками."""
|
||||
mock_settings = create_mock_settings()
|
||||
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
|
||||
return ModemService(), mock_settings
|
||||
|
||||
|
||||
class TestModemServiceAvailability:
|
||||
"""Тесты проверки доступности модема."""
|
||||
|
||||
def test_check_availability_no_subscription(self, monkeypatch):
|
||||
"""Модем недоступен без подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.subscription = None
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.NO_SUBSCRIPTION
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_trial_subscription(self, monkeypatch):
|
||||
"""Модем недоступен для триальной подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
trial_subscription = create_trial_subscription()
|
||||
sample_user.subscription = trial_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.TRIAL_SUBSCRIPTION
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_modem_disabled_in_settings(self, monkeypatch):
|
||||
"""Модем недоступен, если отключён в настройках."""
|
||||
modem_service, mock_settings = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
mock_settings.is_modem_enabled.return_value = False
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.MODEM_DISABLED
|
||||
|
||||
def test_check_availability_success(self, monkeypatch):
|
||||
"""Модем доступен для платной подписки."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user)
|
||||
|
||||
assert result.available
|
||||
assert result.error is None
|
||||
assert not result.modem_enabled
|
||||
|
||||
def test_check_availability_for_enable_already_enabled(self, monkeypatch):
|
||||
"""Нельзя подключить уже подключенный модем."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user, for_enable=True)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.ALREADY_ENABLED
|
||||
assert result.modem_enabled
|
||||
|
||||
def test_check_availability_for_disable_not_enabled(self, monkeypatch):
|
||||
"""Нельзя отключить неподключенный модем."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = False
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
result = modem_service.check_availability(sample_user, for_disable=True)
|
||||
|
||||
assert not result.available
|
||||
assert result.error == ModemError.NOT_ENABLED
|
||||
assert not result.modem_enabled
|
||||
|
||||
|
||||
class TestModemServicePricing:
|
||||
"""Тесты расчёта цены модема."""
|
||||
|
||||
def test_calculate_price_one_month(self, monkeypatch):
|
||||
"""Расчёт цены на 1 месяц."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=30)
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 10000
|
||||
assert result.final_price == 10000
|
||||
assert result.charged_months == 1
|
||||
assert result.discount_percent == 0
|
||||
assert not result.has_discount
|
||||
|
||||
def test_calculate_price_three_months(self, monkeypatch):
|
||||
"""Расчёт цены на 3 месяца."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 30000 # 3 * 10000
|
||||
assert result.charged_months == 3
|
||||
|
||||
def test_calculate_price_with_discount(self, monkeypatch):
|
||||
"""Расчёт цены со скидкой."""
|
||||
modem_service, mock_settings = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
|
||||
mock_settings.get_modem_period_discount.return_value = 10 # 10% скидка
|
||||
|
||||
result = modem_service.calculate_price(sample_subscription)
|
||||
|
||||
assert result.base_price == 30000
|
||||
assert result.discount_percent == 10
|
||||
assert result.discount_amount == 3000
|
||||
assert result.final_price == 27000
|
||||
assert result.has_discount
|
||||
|
||||
|
||||
class TestModemServiceBalance:
|
||||
"""Тесты проверки баланса."""
|
||||
|
||||
def test_check_balance_sufficient(self, monkeypatch):
|
||||
"""Баланса достаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 10000)
|
||||
|
||||
assert has_funds
|
||||
assert missing == 0
|
||||
|
||||
def test_check_balance_insufficient(self, monkeypatch):
|
||||
"""Баланса недостаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 5000
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 10000)
|
||||
|
||||
assert not has_funds
|
||||
assert missing == 5000
|
||||
|
||||
def test_check_balance_zero_price(self, monkeypatch):
|
||||
"""Нулевая цена - всегда достаточно."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_user.balance_kopeks = 0
|
||||
|
||||
has_funds, missing = modem_service.check_balance(sample_user, 0)
|
||||
|
||||
assert has_funds
|
||||
assert missing == 0
|
||||
|
||||
|
||||
class TestModemServicePeriodWarning:
|
||||
"""Тесты предупреждений о сроке действия."""
|
||||
|
||||
def test_warning_critical(self, monkeypatch):
|
||||
"""Критическое предупреждение при <= 7 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(7) == "critical"
|
||||
assert modem_service.get_period_warning_level(5) == "critical"
|
||||
assert modem_service.get_period_warning_level(1) == "critical"
|
||||
|
||||
def test_warning_info(self, monkeypatch):
|
||||
"""Информационное предупреждение при <= 30 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(30) == "info"
|
||||
assert modem_service.get_period_warning_level(15) == "info"
|
||||
assert modem_service.get_period_warning_level(8) == "info"
|
||||
|
||||
def test_warning_none(self, monkeypatch):
|
||||
"""Нет предупреждения при > 30 днях."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_period_warning_level(31) is None
|
||||
assert modem_service.get_period_warning_level(60) is None
|
||||
assert modem_service.get_period_warning_level(90) is None
|
||||
|
||||
|
||||
class TestModemServiceEnable:
|
||||
"""Тесты подключения модема."""
|
||||
|
||||
async def test_enable_modem_success(self, monkeypatch):
|
||||
"""Успешное подключение модема."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_subtract = AsyncMock(return_value=True)
|
||||
mock_create_transaction = AsyncMock()
|
||||
mock_update_remnawave = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.services.modem_service.subtract_user_balance',
|
||||
mock_subtract
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'app.services.modem_service.create_transaction',
|
||||
mock_create_transaction
|
||||
)
|
||||
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
|
||||
|
||||
result = await modem_service.enable_modem(
|
||||
mock_db, sample_user, sample_subscription
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.error is None
|
||||
assert result.charged_amount == 10000
|
||||
assert sample_subscription.modem_enabled is True
|
||||
assert sample_subscription.device_limit == 3 # было 2, стало 3
|
||||
|
||||
async def test_enable_modem_insufficient_funds(self, monkeypatch):
|
||||
"""Недостаточно средств для подключения."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 1000 # недостаточно
|
||||
|
||||
mock_db = AsyncMock()
|
||||
|
||||
result = await modem_service.enable_modem(
|
||||
mock_db, sample_user, sample_subscription
|
||||
)
|
||||
|
||||
assert not result.success
|
||||
assert result.error == ModemError.INSUFFICIENT_FUNDS
|
||||
|
||||
async def test_enable_modem_charge_error(self, monkeypatch):
|
||||
"""Ошибка списания средств."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_user.subscription = sample_subscription
|
||||
sample_user.balance_kopeks = 50000
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_subtract = AsyncMock(return_value=False) # ошибка списания
|
||||
|
||||
monkeypatch.setattr(
|
||||
'app.services.modem_service.subtract_user_balance',
|
||||
mock_subtract
|
||||
)
|
||||
|
||||
result = await modem_service.enable_modem(
|
||||
mock_db, sample_user, sample_subscription
|
||||
)
|
||||
|
||||
assert not result.success
|
||||
assert result.error == ModemError.CHARGE_ERROR
|
||||
|
||||
|
||||
class TestModemServiceDisable:
|
||||
"""Тесты отключения модема."""
|
||||
|
||||
async def test_disable_modem_success(self, monkeypatch):
|
||||
"""Успешное отключение модема."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_user = create_sample_user()
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
sample_subscription.device_limit = 3
|
||||
sample_user.subscription = sample_subscription
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_update_remnawave = AsyncMock()
|
||||
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
|
||||
|
||||
result = await modem_service.disable_modem(
|
||||
mock_db, sample_user, sample_subscription
|
||||
)
|
||||
|
||||
assert result.success
|
||||
assert result.error is None
|
||||
assert sample_subscription.modem_enabled is False
|
||||
assert sample_subscription.device_limit == 2 # было 3, стало 2
|
||||
|
||||
|
||||
class TestModemServiceSingleton:
|
||||
"""Тесты singleton паттерна."""
|
||||
|
||||
def test_get_modem_service_returns_same_instance(self, monkeypatch):
|
||||
"""get_modem_service возвращает один и тот же экземпляр."""
|
||||
# Сбрасываем глобальный экземпляр
|
||||
import app.services.modem_service as modem_module
|
||||
modem_module._modem_service = None
|
||||
|
||||
mock_settings = create_mock_settings()
|
||||
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
|
||||
|
||||
service1 = get_modem_service()
|
||||
service2 = get_modem_service()
|
||||
|
||||
assert service1 is service2
|
||||
|
||||
|
||||
class TestModemEnabledGetter:
|
||||
"""Тесты безопасного получения статуса модема."""
|
||||
|
||||
def test_get_modem_enabled_true(self, monkeypatch):
|
||||
"""Модем включён."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = True
|
||||
|
||||
assert modem_service.get_modem_enabled(sample_subscription) is True
|
||||
|
||||
def test_get_modem_enabled_false(self, monkeypatch):
|
||||
"""Модем выключен."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
sample_subscription = create_sample_subscription()
|
||||
sample_subscription.modem_enabled = False
|
||||
|
||||
assert modem_service.get_modem_enabled(sample_subscription) is False
|
||||
|
||||
def test_get_modem_enabled_none_subscription(self, monkeypatch):
|
||||
"""Подписка None."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
assert modem_service.get_modem_enabled(None) is False
|
||||
|
||||
def test_get_modem_enabled_no_attribute(self, monkeypatch):
|
||||
"""У подписки нет атрибута modem_enabled."""
|
||||
modem_service, _ = create_modem_service(monkeypatch)
|
||||
subscription = SimpleNamespace(id=1) # без modem_enabled
|
||||
|
||||
assert modem_service.get_modem_enabled(subscription) is False
|
||||
Reference in New Issue
Block a user