Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] e7da2a64f8 docker(deps): bump python from 3.13-slim to 3.14-slim
Bumps python from 3.13-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 03:33:10 +00:00
535 changed files with 70997 additions and 160016 deletions
+9 -405
View File
@@ -3,125 +3,26 @@
# ===============================================
# ===== TELEGRAM BOT =====
# Токен бота от @BotFather
# ВАЖНО: Также используется для авторизации виджета личного кабинета (Cabinet WebApp)
# через Telegram.WebApp.initData
BOT_TOKEN=
ADMIN_IDS=
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
SUPPORT_USERNAME=@support
# Имя пользователя бота (опционально, автоопределяется)
# BOT_USERNAME=
# ===== СИСТЕМА ПОДДЕРЖКИ =====
# Включить меню поддержки в интерфейсе
SUPPORT_MENU_ENABLED=true
# Режим системы поддержки: tickets (тикеты), contact (контакт), both (оба)
SUPPORT_SYSTEM_MODE=both
# SLA для тикетов поддержки
SUPPORT_TICKET_SLA_ENABLED=false
SUPPORT_TICKET_SLA_MINUTES=60
SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS=300
SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES=30
# ===== ЛИЧНЫЙ КАБИНЕТ (CABINET) =====
# Включить личный кабинет пользователя (веб-интерфейс для управления подпиской)
CABINET_ENABLED=false
# URL кабинета для ссылок в email (например: https://cabinet.example.com)
CABINET_URL=
# Секретный ключ для JWT токенов (если не указан, используется BOT_TOKEN)
CABINET_JWT_SECRET=
# Время жизни access token в минутах (по умолчанию 15)
CABINET_ACCESS_TOKEN_EXPIRE_MINUTES=15
# Время жизни refresh token в днях (по умолчанию 7)
CABINET_REFRESH_TOKEN_EXPIRE_DAYS=7
# Разрешенные origins для CORS (через запятую, например: https://cabinet.example.com)
CABINET_ALLOWED_ORIGINS=
# Включить верификацию email (требует настройки SMTP)
CABINET_EMAIL_VERIFICATION_ENABLED=false
# Включить регистрацию/вход по email (если false - только Telegram)
CABINET_EMAIL_AUTH_ENABLED=true
# ===== ТЕСТОВЫЙ EMAIL ДЛЯ РАЗРАБОТКИ =====
# Тестовый email для проверки регистрации без SMTP
# При использовании этого email верификация пропускается
TEST_EMAIL=
TEST_EMAIL_PASSWORD=
# Время жизни токена верификации email в часах
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS=24
# Время жизни токена сброса пароля в часах
CABINET_PASSWORD_RESET_EXPIRE_HOURS=1
# Время жизни кода подтверждения смены email в минутах
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES=15
# ===== SMTP НАСТРОЙКИ (для email в личном кабинете) =====
# SMTP сервер (например: smtp.gmail.com, smtp.yandex.ru)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
# Email отправителя (если не указан, используется SMTP_USER)
SMTP_FROM_EMAIL=
SMTP_FROM_NAME=VPN Service
# Использовать TLS шифрование
SMTP_USE_TLS=true
# Уведомления администраторов
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)
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
# ===== МОНИТОРИНГ ТРАФИКА =====
# Логика: при запуске бота создаётся snapshot трафика всех пользователей.
# Через указанный интервал проверяется дельта (разница) трафика.
# Если дельта превышает порог — отправляется уведомление админам.
# Быстрая проверка (дельта трафика за интервал)
TRAFFIC_FAST_CHECK_ENABLED=false # Включить быструю проверку
TRAFFIC_FAST_CHECK_INTERVAL_MINUTES=10 # Интервал проверки в минутах
TRAFFIC_FAST_CHECK_THRESHOLD_GB=5.0 # Порог дельты в ГБ (сколько потрачено за интервал)
# Суточная проверка (трафик за 24 часа через bandwidth API)
TRAFFIC_DAILY_CHECK_ENABLED=false # Включить суточную проверку
TRAFFIC_DAILY_CHECK_TIME=00:00 # Время суточной проверки (HH:MM по UTC)
TRAFFIC_DAILY_THRESHOLD_GB=50.0 # Порог суточного трафика в ГБ
# Куда отправлять уведомления
SUSPICIOUS_NOTIFICATIONS_TOPIC_ID=14 # ID топика для уведомлений о подозрительной активности
# Фильтрация по серверам (UUID нод через запятую)
TRAFFIC_MONITORED_NODES= # Только эти ноды (пусто = все)
TRAFFIC_IGNORED_NODES= # Исключить эти ноды
# Исключить пользователей (UUID через запятую)
TRAFFIC_EXCLUDED_USER_UUIDS= # Служебные/тунельные пользователи
# Производительность
TRAFFIC_CHECK_BATCH_SIZE=1000 # Размер батча для получения пользователей
TRAFFIC_CHECK_CONCURRENCY=10 # Параллельных запросов к API
TRAFFIC_NOTIFICATION_COOLDOWN_MINUTES=60 # Кулдаун уведомлений на пользователя (минуты)
TRAFFIC_SNAPSHOT_TTL_HOURS=24 # TTL snapshot трафика в Redis (часы, сохраняется при рестарте)
# Черный список
BLACKLIST_CHECK_ENABLED=false # Включить проверку пользователей по черному списку
BLACKLIST_GITHUB_URL=https://raw.githubusercontent.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/refs/heads/main/blacklist.txt # URL к файлу черного списка на GitHub
BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновления черного списка с GitHub (в часах)
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
# Обязательная подписка на канал
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"
@@ -143,16 +44,13 @@ LOCALES_PATH=./locales
# Redis
REDIS_URL=redis://redis:6379/0
# Время жизни корзины пользователя в Redis (секунды, по умолчанию 1 час)
CART_TTL_SECONDS=3600
# ===== REMNAWAVE API =====
REMNAWAVE_API_URL=https://panel.example.com
REMNAWAVE_API_KEY=your_api_key_here
# Тип авторизации: "api_key", "basic_auth", "caddy"
# Тип авторизации: "api_key", "basic_auth"
REMNAWAVE_AUTH_TYPE=api_key
REMNAWAVE_CADDY_TOKEN=
# Для панелей с Basic Auth (опционально)
REMNAWAVE_USERNAME=
@@ -182,53 +80,11 @@ REMNAWAVE_USER_USERNAME_TEMPLATE="user_{telegram_id}"
# disable - только деактивировать пользователя
REMNAWAVE_USER_DELETE_MODE=delete
# Автоматическая синхронизация пользователей с панелью Remnawave
REMNAWAVE_AUTO_SYNC_ENABLED=false
# Времена синхронизации (через запятую, формат HH:MM по МСК)
REMNAWAVE_AUTO_SYNC_TIMES=03:00
# ===== REMNAWAVE WEBHOOKS (входящие события из панели) =====
# Включить приём вебхуков от панели Remnawave (real-time события)
REMNAWAVE_WEBHOOK_ENABLED=false
# Путь для приёма вебхуков (должен совпадать с настройкой в панели)
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
# Общий секрет для подписи HMAC-SHA256 (минимум 32 символа)
# Сгенерируйте: openssl rand -hex 32
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
REMNAWAVE_WEBHOOK_SECRET=
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
# Тег для пробных пользователей (опционально)
# TRIAL_USER_TAG=TRIAL
# Тег для платных пользователей (опционально)
# PAID_SUBSCRIPTION_USER_TAG=PAID
# ========= ПОДПИСКИ =========
# ===== РЕЖИМ ПРОДАЖ =====
# Режим продаж подписок (можно переключить в кабинете: Настройки → Подписки):
# "classic" - классический режим:
# - Пользователь выбирает период, серверы, трафик, устройства отдельно
# - Цены периодов берутся из PERIOD_PRICES ниже
# - Подходит для гибкой настройки под каждого пользователя
# "tariffs" - режим тарифов:
# - Пользователь выбирает готовый тариф (Premium, Basic и т.д.)
# - Тарифы создаются в кабинете: Админ → Тарифы
# - Каждый тариф имеет свои серверы, трафик, устройства и цены за периоды
# - Подходит для продажи готовых пакетов услуг
SALES_MODE=tariffs
# ===== ТРИАЛ ПОДПИСКА =====
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
# Длительность триала всё равно берётся из TRIAL_DURATION_DAYS
TRIAL_TARIFF_ID=0
# Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным
# Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты.
# TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости)
TRIAL_PAYMENT_ENABLED=false
TRIAL_ACTIVATION_PRICE=0
@@ -236,7 +92,7 @@ TRIAL_ACTIVATION_PRICE=0
# Сколько устройств доступно по дефолту при покупке платной подписки
DEFAULT_DEVICE_LIMIT=3
# Максимум устройств доступных к покупке (0 = Нет лимита)
# Максимум устройств достопных к покупке (0 = Нет лимита)
MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
@@ -260,36 +116,15 @@ TRAFFIC_SELECTION_MODE=selectable
# 0 = безлимит
FIXED_TRAFFIC_LIMIT_GB=100
# ===== ДОКУПКА ТРАФИКА =====
# Включить/выключить функцию докупки трафика к существующей подписке
TRAFFIC_TOPUP_ENABLED=true
# Показывать кнопку "Докупить трафик" в меню
BUY_TRAFFIC_BUTTON_VISIBLE=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
AVAILABLE_SUBSCRIPTION_PERIODS=30,90,180
AVAILABLE_RENEWAL_PERIODS=30,90,180
# ===== ПРОСТАЯ ПОКУПКА ПОДПИСКИ =====
SIMPLE_SUBSCRIPTION_ENABLED=true
# ===== НАСТРОЙКИ ПРОСТОЙ ПОКУПКИ =====
# Включить упрощённую покупку из меню
SIMPLE_SUBSCRIPTION_ENABLED=false
# Стандартный период (должен совпадать с одним из AVAILABLE_SUBSCRIPTION_PERIODS)
SIMPLE_SUBSCRIPTION_PERIOD_DAYS=30
# Сколько устройств выдаётся в рамках простой подписки
@@ -317,8 +152,6 @@ BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
# Выводимые пакеты трафика и их цены в копейках
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:0:true"
# Цена за безлимитный трафик (в копейках)
PRICE_TRAFFIC_UNLIMITED=20000
# Цена за дополнительное устройство (DEFAULT_DEVICE_LIMIT идет бесплатно!)
PRICE_PER_DEVICE=10000
@@ -327,18 +160,6 @@ 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
# ===== РЕФЕРАЛЬНАЯ СИСТЕМА =====
REFERRAL_PROGRAM_ENABLED=true
REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
@@ -346,39 +167,12 @@ REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
REFERRAL_INVITER_BONUS_KOPEKS=10000
REFERRAL_COMMISSION_PERCENT=25
# Уведомления
REFERRAL_NOTIFICATIONS_ENABLED=true
REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3
# ===== ВЫВОД РЕФЕРАЛЬНОГО БАЛАНСА =====
# Включить функцию вывода реферального баланса
REFERRAL_WITHDRAWAL_ENABLED=false
# Минимальная сумма вывода в копейках (по умолчанию 50000 = 500₽)
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
# Интервал между запросами на вывод (дни)
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
# Выводить только реферальный баланс (true) или весь баланс (false)
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID=0
# Тестовый режим (позволяет админам тестировать функционал)
REFERRAL_WITHDRAWAL_TEST_MODE=false
# Настройки анализа на подозрительную активность
# Минимальная сумма депозита от реферала для анализа (в копейках)
REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS=100000
# Максимум пополнений от одного реферала в месяц
REFERRAL_WITHDRAWAL_SUSPICIOUS_MAX_DEPOSITS_PER_MONTH=10
# Коэффициент подозрительности (пополнено в X раз больше, чем потрачено)
REFERRAL_WITHDRAWAL_SUSPICIOUS_NO_PURCHASES_RATIO=3
# ===== АВТОПРОДЛЕНИЕ =====
# Глобально включить/выключить функцию автопродления (false = функция скрыта)
ENABLE_AUTOPAY=false
# Дни до окончания подписки, когда отправлять предупреждение (через запятую)
AUTOPAY_WARNING_DAYS=3,1
# Включить автопродление для новых пользователей по умолчанию
DEFAULT_AUTOPAY_ENABLED=true
DEFAULT_AUTOPAY_DAYS_BEFORE=3
MIN_BALANCE_FOR_AUTOPAY_KOPEKS=10000
@@ -418,12 +212,6 @@ YOOKASSA_VAT_CODE=1
# 4 - НДС 20%
# 5 - НДС 10/110
# 6 - НДС 20/120
# 7 - НДС 5%
# 8 - НДС 7%
# 9 - НДС 5/105
# 10 - НДС 7/107
# 11 - НДС 22%
# 12 - НДС 22/122
YOOKASSA_PAYMENT_MODE=full_payment
# Способы расчета:
@@ -455,8 +243,6 @@ YOOKASSA_PAYMENT_SUBJECT=service
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_HOST=0.0.0.0
YOOKASSA_WEBHOOK_PORT=8082
# Доверенные сети для webhook (IP-адреса YooKassa, через запятую)
# YOOKASSA_TRUSTED_PROXY_NETWORKS=185.71.76.0/24,185.71.77.0/24
# Лимиты сумм пополнения через YooKassa (в копейках)
YOOKASSA_MIN_AMOUNT_KOPEKS=5000
@@ -467,25 +253,12 @@ YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED=true
# Отключить отображение кнопок выбора суммы пополнения (оставить только ввод вручную)
DISABLE_TOPUP_BUTTONS=false
# Отключить пополнение баланса через поддержку
SUPPORT_TOPUP_ENABLED=true
# Автоматическая проверка зависших пополнений и повторные обращения к провайдерам
PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED=false
# Интервал (в минутах) между автоматическими проверками пополнений
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
# ===== НАЛОГОВАЯ СЛУЖБА (NaloGO) =====
# Автоматическая отправка чеков в налоговую при пополнении баланса
NALOGO_ENABLED=false
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 # Максимум попыток отправки одного чека
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
# чтобы избежать блокировок платежных систем
@@ -533,21 +306,13 @@ MULENPAY_SHOP_ID=<ID магазина>
# необязательно, есть дефолтные значения
MULENPAY_BASE_URL=https://mulenpay.ru/api
MULENPAY_WEBHOOK_PATH=/mulenpay-webhook
# Название кнопки в интерфейсе
MULENPAY_DISPLAY_NAME=Mulen Pay
MULENPAY_DESCRIPTION="Пополнение баланса"
# Запрещённые ключевые слова в display_name (КАЗИНО, СТАВКИ и т.п. — блокируются Mulenpay)
# DISPLAY_NAME_BANNED_KEYWORDS=КАЗИНО,СТАВКИ,CASINO,BET,1XBET
MULENPAY_LANGUAGE=ru
MULENPAY_VAT_CODE=0
MULENPAY_PAYMENT_SUBJECT=4
MULENPAY_PAYMENT_MODE=4
MULENPAY_MIN_AMOUNT_KOPEKS=10000
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
# Ожидаемый origin для iframe (опционально, для безопасности)
# MULENPAY_IFRAME_EXPECTED_ORIGIN=https://mulenpay.ru
# URL для редиректа после оплаты (по умолчанию WEBHOOK_URL)
# MULENPAY_WEBSITE_URL=https://your-cabinet-url.com
# PAYPALYCH / PAL24
PAL24_ENABLED=false
@@ -556,6 +321,7 @@ PAL24_SHOP_ID=
PAL24_SIGNATURE_TOKEN=
PAL24_BASE_URL=https://pal24.pro/api/v1/
PAL24_WEBHOOK_PATH=/pal24-webhook
PAL24_WEBHOOK_PORT=8084
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS=10000
PAL24_MAX_AMOUNT_KOPEKS=100000000
@@ -570,101 +336,17 @@ PLATEGA_ENABLED=false
PLATEGA_MERCHANT_ID=
PLATEGA_SECRET=
PLATEGA_BASE_URL=https://app.platega.io
# Название кнопки в интерфейсе
PLATEGA_DISPLAY_NAME=Platega
PLATEGA_RETURN_URL=
PLATEGA_FAILED_URL=
PLATEGA_CURRENCY=RUB
# Список ID активных методов из кабинета Platega (через запятую)
PLATEGA_ACTIVE_METHODS=2,10,11,12,13
PLATEGA_MIN_AMOUNT_KOPEKS=100
PLATEGA_MIN_AMOUNT_KOPEKS=10000
PLATEGA_MAX_AMOUNT_KOPEKS=100000000
PLATEGA_WEBHOOK_PATH=/platega-webhook
PLATEGA_WEBHOOK_HOST=0.0.0.0
PLATEGA_WEBHOOK_PORT=8086
# ===== FREEKASSA =====
FREEKASSA_ENABLED=false
FREEKASSA_SHOP_ID=
FREEKASSA_API_KEY=
# Секретное слово 1 (для формы оплаты)
FREEKASSA_SECRET_WORD_1=
# Секретное слово 2 (для webhook)
FREEKASSA_SECRET_WORD_2=
FREEKASSA_DISPLAY_NAME=Freekassa
FREEKASSA_CURRENCY=RUB
FREEKASSA_MIN_AMOUNT_KOPEKS=10000
FREEKASSA_MAX_AMOUNT_KOPEKS=100000000
FREEKASSA_PAYMENT_TIMEOUT_SECONDS=3600
FREEKASSA_WEBHOOK_PATH=/freekassa-webhook
FREEKASSA_WEBHOOK_HOST=0.0.0.0
FREEKASSA_WEBHOOK_PORT=8088
# Способ оплаты: пусто = форма выбора, 42 = обычный СБП, 44 = NSPK СБП
FREEKASSA_PAYMENT_SYSTEM_ID=
# Использовать API для создания заказов (обязательно для NSPK СБП)
FREEKASSA_USE_API=false
# ===== KASSA AI (api.fk.life) =====
# Отдельная платёжная система, работает параллельно с Freekassa
KASSA_AI_ENABLED=false
KASSA_AI_SHOP_ID=
KASSA_AI_API_KEY=
# Секретное слово 2 (для webhook)
KASSA_AI_SECRET_WORD_2=
KASSA_AI_DISPLAY_NAME=KassaAI
KASSA_AI_CURRENCY=RUB
KASSA_AI_MIN_AMOUNT_KOPEKS=10000
KASSA_AI_MAX_AMOUNT_KOPEKS=100000000
KASSA_AI_WEBHOOK_PATH=/kassa-ai-webhook
KASSA_AI_WEBHOOK_HOST=0.0.0.0
KASSA_AI_WEBHOOK_PORT=8089
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID=44
# ===== WATA =====
WATA_ENABLED=false
WATA_BASE_URL=https://api.wata.pro
WATA_ACCESS_TOKEN=
WATA_TERMINAL_PUBLIC_ID=
WATA_PAYMENT_DESCRIPTION=Пополнение баланса
# Тип платежа: card, sbp, all
WATA_PAYMENT_TYPE=all
WATA_SUCCESS_REDIRECT_URL=
WATA_FAIL_REDIRECT_URL=
WATA_LINK_TTL_MINUTES=60
WATA_MIN_AMOUNT_KOPEKS=10000
WATA_MAX_AMOUNT_KOPEKS=10000000
WATA_REQUEST_TIMEOUT=30
WATA_WEBHOOK_PATH=/wata-webhook
WATA_WEBHOOK_HOST=0.0.0.0
WATA_WEBHOOK_PORT=8087
# Кэширование публичного ключа WATA (секунды)
WATA_PUBLIC_KEY_CACHE_SECONDS=3600
# URL для получения публичного ключа (опционально)
# WATA_PUBLIC_KEY_URL=
# ===== CLOUDPAYMENTS =====
CLOUDPAYMENTS_ENABLED=false
CLOUDPAYMENTS_PUBLIC_ID=
CLOUDPAYMENTS_API_SECRET=
# URL API CloudPayments
CLOUDPAYMENTS_API_URL=https://api.cloudpayments.ru
# URL виджета оплаты
CLOUDPAYMENTS_WIDGET_URL=https://widget.cloudpayments.ru/show
CLOUDPAYMENTS_DESCRIPTION=Пополнение баланса
CLOUDPAYMENTS_CURRENCY=RUB
CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS=10000
CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS=10000000
CLOUDPAYMENTS_WEBHOOK_PATH=/cloudpayments-webhook
CLOUDPAYMENTS_WEBHOOK_HOST=0.0.0.0
CLOUDPAYMENTS_WEBHOOK_PORT=8089
# URL для возврата после оплаты (опционально)
# CLOUDPAYMENTS_RETURN_URL=
# Скин виджета: mini, classic, modern
CLOUDPAYMENTS_SKIN=mini
CLOUDPAYMENTS_REQUIRE_EMAIL=false
CLOUDPAYMENTS_TEST_MODE=false
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
@@ -673,8 +355,6 @@ LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
MAIN_MENU_MODE=default
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
MENU_LAYOUT_ENABLED=false
# Скрыть блок с ссылкой подключения в разделе с информацией о подписке
HIDE_SUBSCRIPTION_LINK=false
@@ -690,8 +370,6 @@ CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
MINIAPP_STATIC_PATH=miniapp
# URL для редиректа на страницу покупки в мини-приложении (опционально)
# MINIAPP_PURCHASE_URL=
MINIAPP_SERVICE_NAME_EN=Bedolaga VPN
MINIAPP_SERVICE_NAME_RU=Bedolaga VPN
MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection
@@ -703,8 +381,6 @@ HAPP_DOWNLOAD_LINK_IOS=
HAPP_DOWNLOAD_LINK_ANDROID=
HAPP_DOWNLOAD_LINK_MACOS=
HAPP_DOWNLOAD_LINK_WINDOWS=
# Универсальная ссылка для ПК (если MACOS и WINDOWS не заданы отдельно)
HAPP_DOWNLOAD_LINK_PC=
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
@@ -752,15 +428,9 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
AVAILABLE_LANGUAGES=ru,en
# Включить выбор языка при старте и отображение кнопки в меню
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 и т.д.
@@ -770,17 +440,6 @@ APP_CONFIG_PATH=app-config.json
ENABLE_DEEP_LINKS=true
APP_CONFIG_CACHE_TTL=3600
# ===== BAN SYSTEM INTEGRATION (BedolagaBan) =====
# Интеграция с системой мониторинга банов BedolagaBan
# Включить интеграцию с Ban системой
BAN_SYSTEM_ENABLED=false
# URL API сервера Ban системы (например: http://ban-server:8000)
BAN_SYSTEM_API_URL=
# API токен для авторизации в Ban системе
BAN_SYSTEM_API_TOKEN=
# Таймаут запросов к API (секунды)
BAN_SYSTEM_REQUEST_TIMEOUT=30
# ===== СИСТЕМА БЕКАПОВ =====
BACKUP_AUTO_ENABLED=true
BACKUP_INTERVAL_HOURS=24
@@ -797,8 +456,6 @@ BACKUP_SEND_ENABLED=true
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
# Пароль для архива бекапа (опционально). Если задан - бекап отправляется в зашифрованном ZIP с AES
BACKUP_ARCHIVE_PASSWORD=
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
VERSION_CHECK_ENABLED=true
@@ -809,28 +466,6 @@ VERSION_CHECK_INTERVAL_HOURS=1
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# === Ротация логов ===
# Включить новую систему ротации (по умолчанию старое поведение)
LOG_ROTATION_ENABLED=false
# Время ротации (HH:MM)
LOG_ROTATION_TIME=00:00
# Хранить архивы N дней
LOG_ROTATION_KEEP_DAYS=7
# Сжимать архивы gzip
LOG_ROTATION_COMPRESS=true
# Отправлять архивы в Telegram-канал
LOG_ROTATION_SEND_TO_TELEGRAM=false
# Канал для логов (если не задан, используется BACKUP_SEND_CHAT_ID)
LOG_ROTATION_CHAT_ID=
# Топик в канале (если не задан, используется BACKUP_SEND_TOPIC_ID)
LOG_ROTATION_TOPIC_ID=
# Пути к лог-файлам (при LOG_ROTATION_ENABLED=true)
LOG_DIR=logs
LOG_INFO_FILE=info.log
LOG_WARNING_FILE=warning.log
LOG_ERROR_FILE=error.log
LOG_PAYMENTS_FILE=payments.log
# ===== РАЗРАБОТКА =====
DEBUG=false
WEBHOOK_URL=
@@ -841,45 +476,14 @@ WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
WEBHOOK_ENQUEUE_TIMEOUT=0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
BOT_RUN_MODE=polling # polling или webhook
# ===== КОНКУРСНАЯ СИСТЕМА =====
CONTESTS_ENABLED=false
CONTESTS_BUTTON_VISIBLE=false
# Реферальные конкурсы (турниры среди рефералов)
REFERRAL_CONTESTS_ENABLED=false
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
# Автоматическая покупка из сохранённой корзины после пополнения баланса
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
# ===== КНОПКА АКТИВАЦИИ =====
ACTIVATE_BUTTON_VISIBLE=false
# ACTIVATE_BUTTON_TEXT=активировать
BOT_RUN_MODE=polling # polling, webhook или both
# ===== ЕДИНЫЙ ВЕБ-СЕРВЕР =====
WEB_API_ENABLED=false
WEB_API_HOST=0.0.0.0
WEB_API_PORT=8080
# Количество воркеров (для продакшена рекомендуется 2-4)
WEB_API_WORKERS=1
WEB_API_ALLOWED_ORIGINS=*
WEB_API_DOCS_ENABLED=false
# Название и версия API (для документации)
WEB_API_TITLE=Remnawave Bot Admin API
WEB_API_VERSION=1.0.0
# Токен по умолчанию для начальной настройки
WEB_API_DEFAULT_TOKEN=
WEB_API_DEFAULT_TOKEN_NAME=Bootstrap Token
# Алгоритм хеширования токенов
WEB_API_TOKEN_HASH_ALGORITHM=sha256
# Логирование запросов
WEB_API_REQUEST_LOGGING=true
# Внешний админ-токен (для интеграции с другими ботами/системами)
# Токен для доступа через API другого бота
# EXTERNAL_ADMIN_TOKEN=
# ID бота, от которого принимается токен
# EXTERNAL_ADMIN_TOKEN_BOT_ID=
MINIAPP_STATIC_PATH=miniapp
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+4 -4
View File
@@ -14,7 +14,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
jobs:
build-and-push:
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v2.6.2-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v2.6.2-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
VERSION="v2.6.2-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
-27
View File
@@ -1,27 +0,0 @@
name: Lint
on:
push:
branches: ['**']
pull_request:
branches: ['**']
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- run: uv sync --group dev
- name: Check formatting
run: uv run ruff format --check .
- name: Check linting
run: uv run ruff check .
-24
View File
@@ -1,24 +0,0 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
-168
View File
@@ -1,168 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
lint:
uses: ./.github/workflows/lint.yml
release:
needs: lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get previous tag
id: prev_tag
run: |
PREV_TAG=$(git describe --tags --abbrev=0 ${{ github.ref_name }}^ 2>/dev/null || echo "")
echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Generate changelog
id: changelog
run: |
TAG="${{ github.ref_name }}"
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
if [ -z "$PREV_TAG" ]; then
RANGE="$TAG"
else
RANGE="${PREV_TAG}..${TAG}"
fi
# Collect commits by category
FEATURES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^feat" || true)
FIXES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^fix" || true)
PERF=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^perf|^refactor" || true)
DOCS=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^docs|^style" || true)
CHORE=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^chore|^ci|^build|^test" || true)
OTHER=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -ivE "^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)" || true)
# Collect unique contributors
CONTRIBUTORS=$(git log $RANGE --pretty=format:"%an" --no-merges | sort -u)
# Stats
TOTAL_COMMITS=$(git log $RANGE --oneline --no-merges | wc -l | tr -d ' ')
FILES_CHANGED=$(git diff --stat $RANGE 2>/dev/null | tail -1 || echo "N/A")
# Format function
format_section() {
local commits="$1"
if [ -n "$commits" ]; then
echo "$commits" | while IFS='|' read -r msg author hash; do
# Clean conventional commit prefix
clean_msg=$(echo "$msg" | sed -E 's/^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)(\([^)]*\))?:\s*//')
echo "- ${clean_msg} (\`${hash}\`) — @${author}"
done
fi
}
# Build changelog
{
echo "changelog<<CHANGELOG_EOF"
if [ -n "$FEATURES" ]; then
echo "### New Features"
echo ""
format_section "$FEATURES"
echo ""
fi
if [ -n "$FIXES" ]; then
echo "### Bug Fixes"
echo ""
format_section "$FIXES"
echo ""
fi
if [ -n "$PERF" ]; then
echo "### Performance & Refactoring"
echo ""
format_section "$PERF"
echo ""
fi
if [ -n "$DOCS" ]; then
echo "### Documentation & Style"
echo ""
format_section "$DOCS"
echo ""
fi
if [ -n "$CHORE" ]; then
echo "### Maintenance"
echo ""
format_section "$CHORE"
echo ""
fi
if [ -n "$OTHER" ]; then
echo "### Other Changes"
echo ""
format_section "$OTHER"
echo ""
fi
echo "---"
echo ""
echo "### Contributors"
echo ""
if [ -n "$CONTRIBUTORS" ]; then
echo "$CONTRIBUTORS" | while read -r name; do
echo "- @${name}"
done
fi
echo ""
echo "### Stats"
echo ""
echo "- **Commits:** ${TOTAL_COMMITS}"
echo "- **Changes:** ${FILES_CHANGED}"
if [ -n "$PREV_TAG" ]; then
echo "- **Full diff:** [\`${PREV_TAG}...${TAG}\`](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${TAG})"
fi
echo "CHANGELOG_EOF"
} >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
body: |
## What's Changed
${{ steps.changelog.outputs.changelog }}
---
### Docker
```bash
# Docker Hub
docker pull fr1ngg/remnawave-bedolaga-telegram-bot:${{ github.ref_name }}
# GitHub Container Registry
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
```
### Update
```bash
# Docker Compose
docker compose pull && docker compose up -d
# Or with Make
make reload
```
draft: false
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') || contains(github.ref_name, 'dev') }}
generate_release_notes: false
+22 -87
View File
@@ -1,102 +1,37 @@
# Игнорируем все файлы и папки по умолчанию
*
docker-compose.override.yml
# ========== WHITELIST: разрешённые файлы ==========
# Конфигурация проекта
# Исключения: разрешаем только нужные файлы
!.dockerignore
!.env.example
!.gitignore
!.python-version
!Dockerfile
!docker-compose.yml
!docker-compose.local.yml
!Makefile
!pyproject.toml
!uv.lock
!requirements.txt
!alembic.ini
!app-config.json
!release-please-config.json
!.release-please-manifest.json
# Документация
!README.md
!LICENSE
!CONTRIBUTING.md
!SECURITY.md
# Скрипты
!install_bot.sh
!Dockerfile
!app-config.json
!main.py
!requirements.txt
!docs/
!docs/**
# Статические файлы
!vpn_logo.png
# ========== WHITELIST: разрешённые папки ==========
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
!tests/
!tests/**
!migrations/
!migrations/**
!docs/
!docs/**
!assets/
!assets/**
!locales/
!locales/**
!.github/
!.github/**
# ========== BLACKLIST: игнорируемые внутри папок ==========
# Дополнительно разрешаем README и лицензию (опционально)
!README.md
!LICENSE
# Python
__pycache__/
**/__pycache__/
*.py[cod]
*$py.class
*.so
# Разрешаем .gitignore чтобы он попал в репозиторий
!.gitignore
# Virtual environments
.venv/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Build/dist
build/
dist/
*.egg-info/
.eggs/
# Testing/coverage
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Local overrides (не коммитить!)
docker-compose.override.yml
.env
.env.local
.env.*.local
# Runtime data
logs/
data/
*.log
*.db
*.sqlite3
# OS files
.DS_Store
Thumbs.db
# Внутри разрешенных папок игнорируем служебные файлы
app/__pycache__/
app/**/__pycache__/
app/**/*.pyc
app/**/*.pyo
app/**/*.pyd
*.pyc
*.pyo
*.pyd
-1
View File
@@ -1 +0,0 @@
3.13
-3
View File
@@ -1,3 +0,0 @@
{
".": "3.10.1"
}
-231
View File
@@ -1,231 +0,0 @@
# Changelog
## [3.10.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.0...v3.10.1) (2026-02-11)
### Bug Fixes
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
## [3.10.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.1...v3.10.0) (2026-02-10)
### New Features
* add all remaining RemnaWave webhook events (node, service, crm, device) ([1e37fd9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e37fd9dd271814e644af591343cada6ab12d612))
* add close button to all webhook notifications ([d9de15a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9de15a5a06aec3901415bdfd25b55d2ca01d28c))
* add MULENPAY_WEBSITE_URL setting for post-payment redirect ([fe5f5de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe5f5ded965e36300e1c73f25f16de22f84651ad))
* add RemnaWave incoming webhooks for real-time subscription events ([6d67cad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d67cad3e7aa07b8490d88b73c38c4aca6b9e315))
* handle errors.bandwidth_usage_threshold_reached_max_notifications webhook ([8e85e24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e85e244cb786fb4c06162f2b98d01202e893315))
* handle service.subpage_config_changed webhook event ([43a326a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43a326a98ccc3351de04d9b2d660d3e7e0cb0efc))
* unified notification delivery for webhook events (email + WS support) ([26637f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26637f0ae5c7264c0430487d942744fd034e78e8))
* webhook protection — prevent sync/monitoring from overwriting webhook data ([184c52d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/184c52d4ea3ce02d40cf8a5ab42be855c7c7ae23))
### Bug Fixes
* add action buttons to webhook notifications and fix empty device names ([7091eb9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7091eb9c148aaf913c4699fc86fef5b548002668))
* add missing placeholders to Arabic SUBSCRIPTION_INFO template ([fe54640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe546408857128649930de9473c7cde1f7cc450a))
* allow non-HTTP deep links in crypto link webhook updates ([f779225](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f77922522a85b3017be44b5fc71da9c95ec16379))
* build composite device name from platform + hwid short suffix ([17ce640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ce64037f198837c8f2aa7bf863871f60bdf547))
* downgrade transient API errors (502/503/504) to warning level ([ec8eaf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ec8eaf52bfdc2bde612e4fc0324575ba7dc6b2e1))
* extract device name from nested hwidUserDevice object ([79793c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79793c47bbbdae8b0f285448d5f70e90c9d4f4b0))
* preserve payment initiation time in transaction created_at ([90d9df8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90d9df8f0e949913f09c4ebed8fe5280453ab3ab))
* security and architecture fixes for webhook handlers ([dc1e96b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc1e96bbe9b4496e91e9dea591c7fc0ef4cc245b))
* stop CryptoBot webhook retry loop and save cabinet payments to DB ([2cb6d73](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cb6d731e96cbfc305b098d8424b84bfd6826fb4))
* sync subscription status from panel in user.modified webhook ([5156d63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5156d635f0b5bc0493e8f18ce9710cca6ff4ffc8))
* use event field directly as event_name (already includes scope prefix) ([9aa22af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9aa22af3390a249d1b500d75a7d7189daaed265e))
* webhook:close button not working due to channel check timeout ([019fbc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/019fbc12b6cf61d374bbed4bce3823afc60445c9))
## [3.9.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.0...v3.9.1) (2026-02-10)
### Bug Fixes
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
## [3.9.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.8.0...v3.9.0) (2026-02-09)
### New Features
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
### Bug Fixes
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
### Performance
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
### Refactoring
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
## [3.8.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.2...v3.8.0) (2026-02-08)
### New Features
* add admin device management endpoints ([c57de10](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c57de1081a9e905ba191f64c37221c36713c82a6))
* add admin traffic packages and device limit management ([2f90f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f90f9134df58b8c0a329c20060efcf07d5d92f9))
* add admin updates endpoint for bot and cabinet releases ([11b8ab1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11b8ab1959e83fafe405be0b76dfa3dd1580a68b))
* add endpoint for updating user referral commission percent ([da6f746](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da6f746b093be8cdbf4e2889c50b35087fbc90de))
* add enrichment data to CSV export ([f2dbab6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f2dbab617155cdc41573d885f0e55222e5b9825b))
* add server-side sorting for enrichment columns ([15c7cc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15c7cc2a58e1f1935d10712a981466629db251d1))
* add system info endpoint for admin dashboard ([02c30f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02c30f8e7eb6ba90ed8983cfd82199a22b473bbf))
* add traffic usage enrichment endpoint with devices, spending, dates, last node ([5cf3f2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf3f2f76eb2cd93282f845ea0850f6707bfcc09))
* admin panel enhancements & bug fixes ([e6ebf81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebf81752499df8eb0a710072785e3d603dba33))
### Bug Fixes
* add debug logging for bulk device response structure ([46da31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46da31d89c55c225dec9136d225f2db967cf8961))
* add email field to traffic table for OAuth/email users ([94fcf20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94fcf20d17c54efd67fa7bd47eff1afdd1507e08))
* add email/UUID fallback for OAuth user panel sync ([165965d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165965d8ea60a002c061fd75f88b759f2da66d7d))
* add enrichment device mapping debug logs ([5be82f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5be82f2d78aed9b54d74e86f261baa5655e5dcd9))
* include additional devices in tariff renewal price and display ([17e9259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17e9259eb1d41dbf1d313b6a7d500f6458359393))
* paginate bulk device endpoint to fetch all HWID devices ([4648a82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4648a82da959410603c92055bcde7f96131e0c29))
* read bot version from pyproject.toml when VERSION env is not set ([9828ff0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9828ff0845ec1d199a6fa63fe490ad3570cf9c8f))
* revert device pagination, add raw user data field discovery ([8f7fa76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f7fa76e6ab34a3ad2f61f4e1f06026fd3fbf4e3))
* use bulk device endpoint instead of per-user calls ([5f219c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f219c33e6d49b0e3e4405a57f8344a4237f1002))
* use correct pagination params (start/size) for bulk HWID devices ([17af51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17af51ce0bdfa45197384988d56960a1918ab709))
* use per-user panel endpoints for reliable device counts and last node data ([9d39901](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d39901f78ece55c740a5df2603601e5d0b1caca))
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
### Bug Fixes
* handle FK violation in create_yookassa_payment when user is deleted ([55d281b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/55d281b0e37a6e8977ceff792cccb8669560945b))
* remove dots from Remnawave username sanitization ([d6fa86b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d6fa86b870eccbf22327cd205539dd2084f0014e))
## [3.7.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.0...v3.7.1) (2026-02-08)
### Bug Fixes
* release-please config — remove blocked workflow files ([d88ca98](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d88ca980ec67e303e37f0094a2912471929b4cef))
* remove workflow files and pyproject.toml from release-please extra-files ([5070bb3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5070bb34e8a09b2641783f5e818bb624469ad610))
* resolve HWID reset and webhook FK violation ([5f3e426](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f3e426750c2adcb097b92f1a9e7725b1c5c5eba))
* resolve HWID reset context manager bug and webhook FK violation ([a9eee19](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9eee19c95efdc38ecf5fa28f7402a2bbba7dd07))
* resolve merge conflict in release-please config ([0ef4f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ef4f55304751571754f2027105af3e507f75dfd))
* resolve multiple production errors and performance issues ([071c23d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/071c23dd5297c20527442cb5d348d498ebf20af4))
## [3.7.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.6.0...v3.7.0) (2026-02-07)
### Features
* add admin traffic usage API ([aa1cd38](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa1cd3829c5c3671e220d49dd7ec2d83563e2cf9))
* add admin traffic usage API with per-node statistics ([6c2c25d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c2c25d2ccb27446c822e4ed94d9351bfeaf4549))
* add node/status filters and custom date range to traffic page ([ad260d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad260d9fe0b232c9d65176502476212902909660))
* add node/status filters, custom date range, connected devices to traffic page ([9ea533a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ea533a864e345647754f316bd27971fba1420af))
* add node/status filters, date range, devices to traffic page ([ad6522f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad6522f547e68ef5965e70d395ca381b0a032093))
* add risk columns to traffic CSV export ([7c1a142](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c1a1426537e43d14eff0a1c3faeca484611b58b))
* add tariff filter, fix traffic data aggregation ([fa01819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa01819674b2d2abb0d05b470559b09eb43abef8))
* node/status filters + custom date range for traffic page ([a161e2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a161e2f904732b459fef98a67abfaae1214ecfd4))
* tariff filter + fix traffic data aggregation ([1021c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1021c2cdcd07cf2194e59af7b59491108339e61f))
* traffic filters, date range & risk columns in CSV export ([4c40b5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c40b5b370616a9ab40cbf0cccdbc0ac4a3f8278))
### Bug Fixes
* close unclosed HTML tags in version notification ([0b61c7f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b61c7fe482e7bbfbb3421307a96d54addfd91ee))
* close unclosed HTML tags when truncating version notification ([b674550](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6745508da861af9b2ff05d89b4ac9a3933da510))
* correct response parsing for non-legacy node-users endpoint ([a076dfb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a076dfb5503a349450b5aa8aac3c6f40070b715d))
* correct response parsing for non-legacy node-users endpoint ([91ac90c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91ac90c2aecfb990679b3d0c835314dde448886a))
* handle mixed types in traffic sort ([eeed2d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eeed2d6369b07860505c59bcff391e7b17e0ffb7))
* handle mixed types in traffic sort for string fields ([a194be0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a194be0843856b3376167d9ba8a8ef737280998c))
* resolve 429 rate limiting on traffic page ([b12544d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b12544d3ea8f4bbd2d8c941f83ee3ac412157adb))
* resolve 429 rate limiting on traffic page ([924d6bc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/924d6bc09c815c1d188ea1d0e7974f7e803c1d3f))
* use legacy per-node endpoint for traffic aggregation ([cc1c8ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc1c8bacb42a9089021b7ae0fecd1f2717953efb))
* use legacy per-node endpoint with correct response format ([b707b79](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b707b7995b90c6465910a35e9a4403e1408c6568))
* use PaymentService for cabinet YooKassa payments ([61bb8fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61bb8fcafd94509568f134ccdba7769b66cc7d5d))
* use PaymentService for cabinet YooKassa payments to save local DB record ([ff5bba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff5bba3fc5d1e1b08d008b64215e487a9eb70960))
## [3.6.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.5.0...v3.6.0) (2026-02-07)
### Features
* add OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([97be4af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97be4afbffd809fe2786a6d248fc4d3f770cb8cf))
* add panel info, node usage endpoints and campaign to user detail ([287a43b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287a43ba6527ff3464a527821d746a68e5371bbe))
* add panel info, node usage endpoints and campaign to user detail ([0703212](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/070321230bcb868e4bc7a39c287ed3431a4aef4a))
* add TRIAL_DISABLED_FOR setting to disable trial by user type ([c4794db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4794db1dd78f7c48b5da896bdb2f000e493e079))
* add user_id filter to admin tickets endpoint ([8886d0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8886d0dea20aa5a31c6b6f0c3391b3c012b4b34d))
* add user_id filter to admin tickets endpoint ([d3819c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3819c492f88794e4466c2da986fd3a928d7f3df))
* block registration with disposable email addresses ([9ca24ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca24efe434278925c0c1f8d2f2d644a67985c89))
* block registration with disposable email addresses ([116c845](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/116c8453bb371b5eacf5c9d07f497eb449a355cc))
* disable trial by user type (email/telegram/all) ([4e7438b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e7438b9f9c01e30c48fcf2bbe191e9b11598185))
* migrate OAuth state storage from in-memory to Redis ([e9b98b8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b98b837a8552360ef4c41f6cd7a5779aa8b0a7))
* OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([3cbb9ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3cbb9ef024695352959ef9a82bf8b81f0ba1d940))
* return 30-day daily breakdown for node usage ([7102c50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7102c50f52d583add863331e96f3a9de189f581a))
* return 30-day daily breakdown for node usage ([e4c65ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4c65ca220994cf08ed3510f51d9e2808bb2d154))
### Bug Fixes
* increase OAuth HTTP timeout to 30s ([333a3c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/333a3c590120a64f6b2963efab1edd861274840c))
* parse bandwidth stats series format for node usage ([557dbf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/557dbf3ebe777d2137e0e28303dc2a803b15c1c6))
* parse bandwidth stats series format for node usage ([462f7a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/462f7a99b9d5c0b7436dbc3d6ab5db6c6cfa3118))
* pass tariff object instead of tariff_id to set_tariff_promo_groups ([1ffb8a5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ffb8a5b85455396006e1fcddd48f4c9a2ca2700))
* query per-node legacy endpoint for user traffic breakdown ([b94e3ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b94e3edf80e747077992c03882119c7559ad1c31))
* query per-node legacy endpoint for user traffic breakdown ([51ca3e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51ca3e42b75c1870c76a1b25f667629855cfe886))
* reduce node usage to 2 API calls to avoid 429 rate limit ([c68c4e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c68c4e59846abba9c7c78ae91ec18e2e0e329e3c))
* reduce node usage to 2 API calls to avoid 429 rate limit ([f00a051](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f00a051bb323e5ba94a3c38939870986726ed58e))
* use accessible nodes API and fix date format for node usage ([943e9a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/943e9a86aaa449cd3154b0919cfdc52d2a35b509))
* use accessible nodes API and fix date format for node usage ([c4da591](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4da59173155e2eeb69eca21416f816fcbd1fa9c))
## [3.5.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.4.0...v3.5.0) (2026-02-06)
### Features
* add tariff reorder API endpoint ([4c2e11e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2e11e64bed41592f5a12061dcca74ce43e0806))
* pass platform-level fields from RemnaWave config to frontend ([095bc00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/095bc00b33d7082558a8b7252906db2850dce9da))
* serve original RemnaWave config from app-config endpoint ([43762ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43762ce8f4fa7142a1ca62a92b97a027dab2564d))
* tariff reorder API endpoint ([085a617](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/085a61721a8175b3f4fd744614c446d73346f2b7))
### Bug Fixes
* enforce blacklist via middleware ([561708b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/561708b7772ec5b84d6ee049aeba26dc70675583))
* enforce blacklist via middleware instead of per-handler checks ([966a599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/966a599c2c778dce9eea3c61adf6067fb33119f6))
* exclude signature field from Telegram initData HMAC validation ([5b64046](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b6404613772610c595e55bde1249cdf6ec3269d))
* improve button URL resolution and pass uiConfig to frontend ([0ed98c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed98c39b6c95911a38a26a32d0ffbcf9cfd7c80))
* restore unquote for user data parsing in telegram auth ([c2cabbe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c2cabbee097a41a95d16c34d43ab7e70d076c4dc))
### Reverts
* remove signature pop from HMAC validation ([4234769](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4234769e92104a6c4f8f1d522e1fca25bc7b20d0))
+3 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.13-slim AS builder
FROM python:3.14-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
@@ -12,9 +12,9 @@ COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
FROM python:3.13-slim
FROM python:3.14-slim
ARG VERSION="v3.10.1" # x-release-please-version
ARG VERSION="v2.6.2"
ARG BUILD_DATE
ARG VCS_REF
-25
View File
@@ -1,28 +1,3 @@
"Commons Clause" License Condition v1.0
The Software is provided to you by the Licensor under the License,
as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights
under the License will not include, and the License does not grant to
you, the right to Sell the Software.
For purposes of the foregoing, "Sell" means practicing any or all of
the rights granted to you under the License to provide to third parties,
for a fee or other consideration (including without limitation fees for
hosting or consulting/support services related to the Software), a
product or service whose value derives, entirely or substantially, from
the functionality of the Software.
Any license notice or attribution required by the License must also
include this Commons Clause License Condition notice.
Software: remnawave-bedolaga-telegram-bot
License: MIT
Licensor: Fr1ngg
---
MIT License
Copyright (c) 2025 Fr1ngg
+5 -15
View File
@@ -25,25 +25,15 @@ reload-follow: ## Перезапустить контейнеры с логам
.PHONY: test
test: ## Запустить тесты
uv run pytest -v
.PHONY: lint
lint: ## Проверить код (ruff check)
uv run ruff check .
.PHONY: format
format: ## Форматировать код (ruff format)
uv run ruff format .
.PHONY: fix
fix: ## Исправить код (ruff check --fix + format)
uv run ruff check . --fix
uv run ruff format .
@echo "🧪 Запускаем тесты..."
pytest -v
.PHONY: help
help: ## Показать список доступных команд
@echo ""
@echo "📘 Команды Makefile:"
@echo ""
@awk -F':.*## ' '/^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
sed -E 's/:.*?## /| /' | \
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@echo ""
+339 -1135
View File
File diff suppressed because it is too large Load Diff
+81 -104
View File
@@ -1,78 +1,68 @@
import logging
import redis.asyncio as redis
from aiogram import Bot, Dispatcher, types
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.storage.redis import RedisStorage
from aiogram.fsm.storage.memory import MemoryStorage
import redis.asyncio as redis
from app.config import settings
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.auth import AuthMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.throttling import ThrottlingMiddleware
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.handlers import (
balance,
common,
contests as user_contests,
start,
menu,
polls as user_polls,
subscription,
balance,
promocode,
referral,
server_status,
simple_subscription,
start,
subscription,
support,
server_status,
common,
tickets,
)
from app.handlers import polls as user_polls
from app.handlers import simple_subscription
from app.handlers.admin import (
backup as admin_backup,
blacklist as admin_blacklist,
blocked_users as admin_blocked_users,
bot_configuration as admin_bot_configuration,
bulk_ban as admin_bulk_ban,
campaigns as admin_campaigns,
contests as admin_contests,
daily_contests as admin_daily_contests,
faq as admin_faq,
main as admin_main,
maintenance as admin_maintenance,
users as admin_users,
subscriptions as admin_subscriptions,
promocodes as admin_promocodes,
messages as admin_messages,
monitoring as admin_monitoring,
payments as admin_payments,
referrals as admin_referrals,
rules as admin_rules,
remnawave as admin_remnawave,
statistics as admin_statistics,
polls as admin_polls,
servers as admin_servers,
maintenance as admin_maintenance,
promo_groups as admin_promo_groups,
campaigns as admin_campaigns,
promo_offers as admin_promo_offers,
user_messages as admin_user_messages,
updates as admin_updates,
backup as admin_backup,
system_logs as admin_system_logs,
welcome_text as admin_welcome_text,
tickets as admin_tickets,
reports as admin_reports,
bot_configuration as admin_bot_configuration,
pricing as admin_pricing,
privacy_policy as admin_privacy_policy,
promo_groups as admin_promo_groups,
promo_offers as admin_promo_offers,
promocodes as admin_promocodes,
public_offer as admin_public_offer,
referrals as admin_referrals,
remnawave as admin_remnawave,
reports as admin_reports,
rules as admin_rules,
servers as admin_servers,
statistics as admin_statistics,
subscriptions as admin_subscriptions,
system_logs as admin_system_logs,
tariffs as admin_tariffs,
tickets as admin_tickets,
trials as admin_trials,
updates as admin_updates,
user_messages as admin_user_messages,
users as admin_users,
welcome_text as admin_welcome_text,
faq as admin_faq,
payments as admin_payments,
)
from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
from app.middlewares.subscription_checker import SubscriptionStatusMiddleware
from app.middlewares.throttling import ThrottlingMiddleware
from app.services.maintenance_service import maintenance_service
from app.utils.cache import cache
from app.utils.message_patch import patch_message_methods
from app.utils.message_patch import patch_message_methods
patch_message_methods()
@@ -80,37 +70,42 @@ logger = logging.getLogger(__name__)
async def debug_callback_handler(callback: types.CallbackQuery):
logger.info('🔍 DEBUG CALLBACK:')
logger.info(f' - Data: {callback.data}')
logger.info(f' - User: {callback.from_user.id}')
logger.info(f' - Username: {callback.from_user.username}')
logger.info(f"🔍 DEBUG CALLBACK:")
logger.info(f" - Data: {callback.data}")
logger.info(f" - User: {callback.from_user.id}")
logger.info(f" - Username: {callback.from_user.username}")
async def setup_bot() -> tuple[Bot, Dispatcher]:
try:
await cache.connect()
logger.info('Кеш инициализирован')
logger.info("Кеш инициализирован")
except Exception as e:
logger.warning(f'Кеш не инициализирован: {e}')
logger.warning(f"Кеш не инициализирован: {e}")
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
maintenance_service.set_bot(bot)
logger.info('Бот установлен в maintenance_service')
logger.info("Бот установлен в maintenance_service")
try:
redis_client = redis.from_url(settings.REDIS_URL)
await redis_client.ping()
storage = RedisStorage(redis_client)
logger.info('Подключено к Redis для FSM storage')
logger.info("Подключено к Redis для FSM storage")
except Exception as e:
logger.warning(f'Не удалось подключиться к Redis: {e}')
logger.info('Используется MemoryStorage для FSM')
logger.warning(f"Не удалось подключиться к Redis: {e}")
logger.info("Используется MemoryStorage для FSM")
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
dp.message.middleware(GlobalErrorMiddleware())
@@ -120,10 +115,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
blacklist_middleware = BlacklistMiddleware()
dp.message.middleware(blacklist_middleware)
dp.callback_query.middleware(blacklist_middleware)
dp.pre_checkout_query.middleware(blacklist_middleware)
display_name_middleware = DisplayNameRestrictionMiddleware()
dp.message.middleware(display_name_middleware)
dp.callback_query.middleware(display_name_middleware)
@@ -131,21 +122,15 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
# Middleware для автоматического логирования кликов по кнопкам
if settings.MENU_LAYOUT_ENABLED:
button_stats_middleware = ButtonStatsMiddleware()
dp.callback_query.middleware(button_stats_middleware)
logger.info('📊 ButtonStatsMiddleware активирован')
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
channel_checker_middleware = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker_middleware)
dp.callback_query.middleware(channel_checker_middleware)
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
logger.info("🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован")
else:
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
logger.info("🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован")
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
@@ -163,7 +148,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_main.register_handlers(dp)
admin_users.register_handlers(dp)
admin_subscriptions.register_handlers(dp)
admin_servers.register_handlers(dp)
admin_servers.register_handlers(dp)
admin_promocodes.register_handlers(dp)
admin_messages.register_handlers(dp)
admin_monitoring.register_handlers(dp)
@@ -174,8 +159,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_polls.register_handlers(dp)
admin_promo_groups.register_handlers(dp)
admin_campaigns.register_handlers(dp)
admin_contests.register_handlers(dp)
admin_daily_contests.register_handlers(dp)
admin_promo_offers.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
@@ -191,44 +174,38 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_public_offer.register_handlers(dp)
admin_faq.register_handlers(dp)
admin_payments.register_handlers(dp)
admin_trials.register_handlers(dp)
admin_tariffs.register_handlers(dp)
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
admin_blocked_users.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
user_polls.register_handlers(dp)
simple_subscription.register_simple_subscription_handlers(dp)
logger.info('⭐ Зарегистрированы обработчики Telegram Stars платежей')
logger.info('⚡ Зарегистрированы обработчики простой покупки')
logger.info('⚡ Зарегистрированы обработчики простой подписки')
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
logger.info("⚡ Зарегистрированы обработчики простой покупки")
logger.info("⚡ Зарегистрированы обработчики простой подписки")
if settings.is_maintenance_monitoring_enabled():
try:
await maintenance_service.start_monitoring()
logger.info('Мониторинг техработ запущен')
logger.info("Мониторинг техработ запущен")
except Exception as e:
logger.error(f'Ошибка запуска мониторинга техработ: {e}')
logger.error(f"Ошибка запуска мониторинга техработ: {e}")
else:
logger.info('Мониторинг техработ отключен настройками')
logger.info('🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries')
logger.info('Бот успешно настроен')
logger.info("Мониторинг техработ отключен настройками")
logger.info("🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries")
logger.info("Бот успешно настроен")
return bot, dp
async def shutdown_bot():
try:
await maintenance_service.stop_monitoring()
logger.info('Мониторинг техработ остановлен')
logger.info("Мониторинг техработ остановлен")
except Exception as e:
logger.error(f'Ошибка остановки мониторинга: {e}')
logger.error(f"Ошибка остановки мониторинга: {e}")
try:
await cache.close()
logger.info('Соединения с кешем закрыты')
logger.info("Соединения с кешем закрыты")
except Exception as e:
logger.error(f'Ошибка закрытия кеша: {e}')
logger.error(f"Ошибка закрытия кеша: {e}")
-10
View File
@@ -1,10 +0,0 @@
"""
Cabinet module - Personal Account for VPN Bot users.
This module provides:
- JWT-based authentication (Telegram + Email)
- Subscription management
- Balance & payments
- Referral program
- Support tickets
"""
-22
View File
@@ -1,22 +0,0 @@
"""Cabinet authentication module."""
from .jwt_handler import (
create_access_token,
create_refresh_token,
decode_token,
get_token_payload,
)
from .password_utils import hash_password, verify_password
from .telegram_auth import validate_telegram_init_data, validate_telegram_login_widget
__all__ = [
'create_access_token',
'create_refresh_token',
'decode_token',
'get_token_payload',
'hash_password',
'validate_telegram_init_data',
'validate_telegram_login_widget',
'verify_password',
]
-84
View File
@@ -1,84 +0,0 @@
"""Email verification token generation and validation."""
import secrets
from datetime import datetime, timedelta
from app.config import settings
def generate_email_change_code() -> str:
"""
Generate a 6-digit verification code for email change.
Returns:
6-digit numeric string
"""
return str(secrets.randbelow(900000) + 100000)
def get_email_change_expires_at() -> datetime:
"""
Get the expiration datetime for an email change code.
Returns:
Datetime when the email change code expires
"""
minutes = settings.get_cabinet_email_change_code_expire_minutes()
return datetime.utcnow() + timedelta(minutes=minutes)
def generate_verification_token() -> str:
"""
Generate a secure random verification token.
Returns:
32-character hex token string
"""
return secrets.token_hex(32)
def generate_password_reset_token() -> str:
"""
Generate a secure random password reset token.
Returns:
32-character hex token string
"""
return secrets.token_hex(32)
def get_verification_expires_at() -> datetime:
"""
Get the expiration datetime for a verification token.
Returns:
Datetime when the verification token expires
"""
hours = settings.get_cabinet_email_verification_expire_hours()
return datetime.utcnow() + timedelta(hours=hours)
def get_password_reset_expires_at() -> datetime:
"""
Get the expiration datetime for a password reset token.
Returns:
Datetime when the password reset token expires
"""
hours = settings.get_cabinet_password_reset_expire_hours()
return datetime.utcnow() + timedelta(hours=hours)
def is_token_expired(expires_at: datetime | None) -> bool:
"""
Check if a token has expired.
Args:
expires_at: Token expiration datetime
Returns:
True if expired or no expiration set, False otherwise
"""
if expires_at is None:
return True
return datetime.utcnow() > expires_at
-111
View File
@@ -1,111 +0,0 @@
"""JWT token handling for cabinet authentication."""
from datetime import datetime, timedelta
from typing import Any
import jwt
from app.config import settings
JWT_ALGORITHM = 'HS256'
def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
"""
Create a short-lived access token.
Args:
user_id: Database user ID
telegram_id: Telegram user ID (optional for email-only users)
Returns:
Encoded JWT access token
"""
expire_minutes = settings.get_cabinet_access_token_expire_minutes()
expires = datetime.utcnow() + timedelta(minutes=expire_minutes)
payload = {
'sub': str(user_id),
'type': 'access',
'exp': expires,
'iat': datetime.utcnow(),
}
# Добавляем telegram_id только если он есть
if telegram_id is not None:
payload['telegram_id'] = telegram_id
secret = settings.get_cabinet_jwt_secret()
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
def create_refresh_token(user_id: int) -> str:
"""
Create a long-lived refresh token.
Args:
user_id: Database user ID
Returns:
Encoded JWT refresh token
"""
expire_days = settings.get_cabinet_refresh_token_expire_days()
expires = datetime.utcnow() + timedelta(days=expire_days)
payload = {
'sub': str(user_id),
'type': 'refresh',
'exp': expires,
'iat': datetime.utcnow(),
}
secret = settings.get_cabinet_jwt_secret()
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> dict[str, Any] | None:
"""
Decode and validate a JWT token.
Args:
token: JWT token string
Returns:
Decoded payload dict or None if invalid/expired
"""
try:
secret = settings.get_cabinet_jwt_secret()
return jwt.decode(token, secret, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, Any] | None:
"""
Decode token and verify its type.
Args:
token: JWT token string
expected_type: Expected token type ("access" or "refresh")
Returns:
Decoded payload dict or None if invalid/expired/wrong type
"""
payload = decode_token(token)
if not payload:
return None
if payload.get('type') != expected_type:
return None
return payload
def get_refresh_token_expires_at() -> datetime:
"""Get the expiration datetime for a new refresh token."""
expire_days = settings.get_cabinet_refresh_token_expire_days()
return datetime.utcnow() + timedelta(days=expire_days)
-429
View File
@@ -1,429 +0,0 @@
"""OAuth 2.0 provider implementations for cabinet authentication."""
import logging
import secrets
from abc import ABC, abstractmethod
from typing import Any, TypedDict
import httpx
from pydantic import BaseModel
from app.config import settings
from app.utils.cache import cache, cache_key
logger = logging.getLogger(__name__)
STATE_TTL_SECONDS = 600 # 10 minutes
# --- Typed dicts for provider API responses ---
class OAuthProviderConfig(TypedDict):
client_id: str
client_secret: str
enabled: bool
display_name: str
class OAuthTokenResponse(TypedDict, total=False):
access_token: str
token_type: str
expires_in: int
refresh_token: str
scope: str
# VK-specific: email and user_id come in token response
email: str
user_id: int
class GoogleUserInfoResponse(TypedDict, total=False):
sub: str
email: str
email_verified: bool
given_name: str
family_name: str
picture: str
name: str
class YandexUserInfoResponse(TypedDict, total=False):
id: str
login: str
default_email: str
emails: list[str]
first_name: str
last_name: str
default_avatar_id: str
class DiscordUserInfoResponse(TypedDict, total=False):
id: str
username: str
global_name: str
email: str
verified: bool
avatar: str
class VKUserInfoItem(TypedDict, total=False):
id: int
first_name: str
last_name: str
photo_200: str
class VKUserInfoResponse(TypedDict, total=False):
response: list[VKUserInfoItem]
# --- Models ---
class OAuthUserInfo(BaseModel):
"""Normalized user info from OAuth provider."""
provider: str
provider_id: str
email: str | None = None
email_verified: bool = False
first_name: str | None = None
last_name: str | None = None
username: str | None = None
avatar_url: str | None = None
# --- CSRF state management (Redis) ---
async def generate_oauth_state(provider: str) -> str:
"""Generate a CSRF state token for OAuth flow. Stored in Redis with TTL."""
state = secrets.token_urlsafe(32)
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
return state
async def validate_oauth_state(state: str, provider: str) -> bool:
"""Validate and consume a CSRF state token from Redis."""
key = cache_key('oauth_state', state)
stored_provider: str | None = await cache.get(key)
if stored_provider is None:
return False
await cache.delete(key)
if stored_provider != provider:
return False
return True
# --- Provider implementations ---
class OAuthProvider(ABC):
"""Base class for OAuth 2.0 providers."""
name: str
display_name: str
def __init__(self, client_id: str, client_secret: str, redirect_uri: str) -> None:
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
@abstractmethod
def get_authorization_url(self, state: str) -> str:
"""Build the authorization URL for the provider."""
@abstractmethod
async def exchange_code(self, code: str) -> OAuthTokenResponse:
"""Exchange authorization code for tokens."""
@abstractmethod
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
"""Fetch user info from the provider."""
class GoogleProvider(OAuthProvider):
name = 'google'
display_name = 'Google'
AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
TOKEN_URL = 'https://oauth2.googleapis.com/token'
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
def get_authorization_url(self, state: str) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'openid email profile',
'state': state,
'access_type': 'offline',
'prompt': 'select_account',
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
json={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': self.redirect_uri,
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.USERINFO_URL,
headers={'Authorization': f'Bearer {access_token}'},
)
response.raise_for_status()
data: GoogleUserInfoResponse = response.json()
return OAuthUserInfo(
provider='google',
provider_id=str(data['sub']),
email=data.get('email'),
email_verified=data.get('email_verified', False),
first_name=data.get('given_name'),
last_name=data.get('family_name'),
avatar_url=data.get('picture'),
)
class YandexProvider(OAuthProvider):
name = 'yandex'
display_name = 'Yandex'
AUTHORIZE_URL = 'https://oauth.yandex.com/authorize'
TOKEN_URL = 'https://oauth.yandex.com/token'
USERINFO_URL = 'https://login.yandex.ru/info'
def get_authorization_url(self, state: str) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'login:info login:email',
'state': state,
'force_confirm': 'yes',
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
data={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.USERINFO_URL,
params={'format': 'json'},
headers={'Authorization': f'OAuth {access_token}'},
)
response.raise_for_status()
data: YandexUserInfoResponse = response.json()
default_email = data.get('default_email')
emails = data.get('emails', [])
email = default_email or (emails[0] if emails else None)
return OAuthUserInfo(
provider='yandex',
provider_id=str(data['id']),
email=email,
email_verified=bool(email),
first_name=data.get('first_name'),
last_name=data.get('last_name'),
username=data.get('login'),
avatar_url=(
f'https://avatars.yandex.net/get-yapic/{data["default_avatar_id"]}/islands-200'
if data.get('default_avatar_id')
else None
),
)
class DiscordProvider(OAuthProvider):
name = 'discord'
display_name = 'Discord'
AUTHORIZE_URL = 'https://discord.com/api/oauth2/authorize'
TOKEN_URL = 'https://discord.com/api/oauth2/token'
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
def get_authorization_url(self, state: str) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'identify email',
'state': state,
'prompt': 'consent',
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
data={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': self.redirect_uri,
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.USERINFO_URL,
headers={'Authorization': f'Bearer {access_token}'},
)
response.raise_for_status()
data: DiscordUserInfoResponse = response.json()
avatar_url: str | None = None
if data.get('avatar'):
avatar_url = f'https://cdn.discordapp.com/avatars/{data["id"]}/{data["avatar"]}.png'
return OAuthUserInfo(
provider='discord',
provider_id=str(data['id']),
email=data.get('email'),
email_verified=data.get('verified', False),
first_name=data.get('global_name') or data.get('username'),
username=data.get('username'),
avatar_url=avatar_url,
)
class VKProvider(OAuthProvider):
name = 'vk'
display_name = 'VK'
AUTHORIZE_URL = 'https://oauth.vk.com/authorize'
TOKEN_URL = 'https://oauth.vk.com/access_token'
USERINFO_URL = 'https://api.vk.com/method/users.get'
API_VERSION = '5.131'
def get_authorization_url(self, state: str) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'email',
'state': state,
'v': self.API_VERSION,
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.TOKEN_URL,
params={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'redirect_uri': self.redirect_uri,
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
user_id: int | None = token_data.get('user_id')
# VK returns email in token response, not in userinfo
email: str | None = token_data.get('email')
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.USERINFO_URL,
params={
'access_token': access_token,
'fields': 'photo_200',
'v': self.API_VERSION,
},
)
response.raise_for_status()
data: VKUserInfoResponse = response.json()
users: list[Any] = data.get('response', [])
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
return OAuthUserInfo(
provider='vk',
provider_id=str(user_id or user_data.get('id', '')),
email=email,
email_verified=bool(email),
first_name=user_data.get('first_name'),
last_name=user_data.get('last_name'),
avatar_url=user_data.get('photo_200'),
)
# --- Provider factory ---
_PROVIDERS: dict[str, type[OAuthProvider]] = {
'google': GoogleProvider,
'yandex': YandexProvider,
'discord': DiscordProvider,
'vk': VKProvider,
}
def get_provider(name: str) -> OAuthProvider | None:
"""Get an OAuth provider instance if enabled.
Returns None if the provider is not enabled or not found.
"""
providers_config: dict[str, OAuthProviderConfig] = settings.get_oauth_providers_config()
config = providers_config.get(name)
if not config or not config['enabled']:
return None
provider_class = _PROVIDERS.get(name)
if not provider_class:
return None
redirect_uri = f'{settings.CABINET_URL}/auth/oauth/callback'
return provider_class(
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri=redirect_uri,
)
-41
View File
@@ -1,41 +0,0 @@
"""Password hashing utilities using bcrypt."""
import bcrypt
BCRYPT_ROUNDS = 12
def hash_password(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password string
"""
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode('utf-8')
def verify_password(password: str, password_hash: str) -> bool:
"""
Verify a password against its hash.
Args:
password: Plain text password to verify
password_hash: Previously hashed password
Returns:
True if password matches, False otherwise
"""
try:
password_bytes = password.encode('utf-8')
hash_bytes = password_hash.encode('utf-8')
return bcrypt.checkpw(password_bytes, hash_bytes)
except (ValueError, TypeError):
return False
-127
View File
@@ -1,127 +0,0 @@
"""Telegram authentication validation for cabinet."""
import hashlib
import hmac
import json
from datetime import datetime
from typing import Any
from urllib.parse import parse_qsl, unquote
from app.config import settings
def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int = 86400) -> bool:
"""
Validate Telegram Login Widget data.
https://core.telegram.org/widgets/login#checking-authorization
Args:
data: Dictionary with Telegram login data (id, first_name, auth_date, hash, etc.)
max_age_seconds: Maximum allowed age of auth_date (default 24 hours)
Returns:
True if data is valid, False otherwise
"""
auth_data = data.copy()
check_hash = auth_data.pop('hash', None)
if not check_hash:
return False
# Check auth_date is not too old
auth_date = auth_data.get('auth_date')
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
if age > max_age_seconds:
return False
except (ValueError, TypeError, OSError):
return False
# Build data-check-string (sorted key=value pairs, newline-separated)
data_check_arr = [f'{k}={v}' for k, v in sorted(auth_data.items()) if v is not None]
data_check_string = '\n'.join(data_check_arr)
# Create secret key from bot token using SHA256
bot_token = settings.BOT_TOKEN
secret_key = hashlib.sha256(bot_token.encode()).digest()
# Calculate expected hash
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(calculated_hash, check_hash)
def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) -> dict[str, Any] | None:
"""
Validate Telegram WebApp initData.
https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
Args:
init_data: Raw initData string from Telegram WebApp
max_age_seconds: Maximum allowed age of auth_date (default 24 hours)
Returns:
Parsed user data dict if valid, None otherwise
"""
try:
# Parse the init_data string
parsed = dict(parse_qsl(init_data, keep_blank_values=True))
received_hash = parsed.pop('hash', None)
if not received_hash:
return None
# Check auth_date is not too old
auth_date = parsed.get('auth_date')
if auth_date:
try:
# Use UTC timestamp to avoid timezone issues
auth_time = datetime.utcfromtimestamp(int(auth_date))
age = (datetime.utcnow() - auth_time).total_seconds()
if age > max_age_seconds:
return None
except (ValueError, TypeError, OSError):
return None
# Build data-check-string
data_check_arr = [f'{k}={v}' for k, v in sorted(parsed.items())]
data_check_string = '\n'.join(data_check_arr)
# Create secret key: HMAC_SHA256(bot_token, "WebAppData")
bot_token = settings.BOT_TOKEN
secret_key = hmac.new(b'WebAppData', bot_token.encode(), hashlib.sha256).digest()
# Calculate expected hash
calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
return None
# Parse user data from the validated data
user_data_str = parsed.get('user')
if user_data_str:
user_data = json.loads(unquote(user_data_str))
return user_data
return parsed
except (ValueError, TypeError, json.JSONDecodeError):
return None
def extract_telegram_user_from_init_data(init_data: str) -> dict[str, Any] | None:
"""
Extract and validate user info from Telegram WebApp initData.
Args:
init_data: Raw initData string from Telegram WebApp
Returns:
User data dict with id, first_name, last_name, username, etc. or None if invalid
"""
return validate_telegram_init_data(init_data)
-228
View File
@@ -1,228 +0,0 @@
"""FastAPI dependencies for cabinet module."""
import asyncio
import logging
from aiogram import Bot
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import get_user_by_id
from app.database.database import AsyncSessionLocal
from app.database.models import User
from app.services.blacklist_service import blacklist_service
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False)
# Кешированный Bot для проверки подписки на канал
_channel_check_bot: Bot | None = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
async def get_cabinet_db() -> AsyncSession:
"""Get database session for cabinet operations."""
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def get_current_cabinet_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User:
"""
Get current authenticated cabinet user from JWT token.
Args:
credentials: HTTP Bearer credentials
db: Database session
Returns:
Authenticated User object
Raises:
HTTPException: If token is invalid, expired, or user not found
"""
# Check maintenance mode first (except for admins - checked later)
if maintenance_service.is_maintenance_active():
# We need to check token first to see if user is admin
pass # Will check after getting user
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Authentication required',
headers={'WWW-Authenticate': 'Bearer'},
)
token = credentials.credentials
payload = get_token_payload(token, expected_type='access')
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid or expired token',
headers={'WWW-Authenticate': 'Bearer'},
)
try:
user_id = int(payload.get('sub'))
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid token payload',
headers={'WWW-Authenticate': 'Bearer'},
)
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
if user.status != 'active':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User account is not active',
)
# Check blacklist
if user.telegram_id is not None:
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
if is_blacklisted:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'blacklisted',
'message': reason or 'Доступ запрещен',
},
)
# Check maintenance mode (allow admins to pass)
if maintenance_service.is_maintenance_active():
# Проверяем админа по telegram_id ИЛИ email
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
if not is_admin:
status_info = maintenance_service.get_status_info()
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
'code': 'maintenance',
'message': maintenance_service.get_maintenance_message() or 'Service is under maintenance',
'reason': status_info.get('reason'),
},
)
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
# Пропускаем проверку для email-only юзеров (нет telegram_id)
if user.telegram_id is not None:
# Проверяем админа по telegram_id ИЛИ email
is_admin = settings.is_admin(
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
)
if not is_admin:
try:
bot = _get_channel_check_bot()
chat_member = await asyncio.wait_for(
bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=user.telegram_id),
timeout=10.0,
)
# Не закрываем сессию - бот переиспользуется
if chat_member.status not in ['member', 'administrator', 'creator']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to our channel to continue',
'channel_link': settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except TimeoutError:
logger.warning(f'Timeout checking channel subscription for user {user.telegram_id}')
# Don't block user if check times out
except Exception as e:
logger.warning(f'Failed to check channel subscription for user {user.telegram_id}: {e}')
# Don't block user if check fails
return user
async def get_optional_cabinet_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
db: AsyncSession = Depends(get_cabinet_db),
) -> User | None:
"""
Optionally get current authenticated cabinet user.
Returns None if no valid token is provided instead of raising an exception.
"""
if not credentials:
return None
token = credentials.credentials
payload = get_token_payload(token, expected_type='access')
if not payload:
return None
try:
user_id = int(payload.get('sub'))
except (TypeError, ValueError):
return None
user = await get_user_by_id(db, user_id)
if not user or user.status != 'active':
return None
return user
async def get_current_admin_user(
user: User = Depends(get_current_cabinet_user),
) -> User:
"""
Get current authenticated admin user.
Checks if the user is admin by telegram_id or email.
Args:
user: Authenticated User object
Returns:
Authenticated admin User object
Raises:
HTTPException: If user is not an admin
"""
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
if not is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Admin access required',
)
return user
-96
View File
@@ -1,96 +0,0 @@
"""Cabinet API routes."""
from fastapi import APIRouter
from .admin_apps import router as admin_apps_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
from .admin_promo_offers import router as admin_promo_offers_router
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
from .admin_remnawave import router as admin_remnawave_router
from .admin_servers import router as admin_servers_router
from .admin_settings import router as admin_settings_router
from .admin_stats import router as admin_stats_router
from .admin_tariffs import router as admin_tariffs_router
from .admin_tickets import router as admin_tickets_router
from .admin_traffic import router as admin_traffic_router
from .admin_updates import router as admin_updates_router
from .admin_users import router as admin_users_router
from .admin_wheel import router as admin_wheel_router
from .auth import router as auth_router
from .balance import router as balance_router
from .branding import router as branding_router
from .contests import router as contests_router
from .info import router as info_router
from .media import router as media_router
from .notifications import router as notifications_router
from .oauth import router as oauth_router
from .polls import router as polls_router
from .promo import router as promo_router
from .promocode import router as promocode_router
from .referral import router as referral_router
from .subscription import router as subscription_router
from .ticket_notifications import (
admin_router as admin_ticket_notifications_router,
router as ticket_notifications_router,
)
from .tickets import router as tickets_router
from .websocket import router as websocket_router
from .wheel import router as wheel_router
# Main cabinet router
router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
# Include all sub-routers
router.include_router(auth_router)
router.include_router(oauth_router)
router.include_router(subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
# Notifications router MUST be before tickets router to avoid route conflict
router.include_router(ticket_notifications_router)
router.include_router(tickets_router)
router.include_router(promocode_router)
router.include_router(contests_router)
router.include_router(polls_router)
router.include_router(promo_router)
router.include_router(notifications_router)
router.include_router(info_router)
router.include_router(branding_router)
router.include_router(media_router)
# Wheel routes
router.include_router(wheel_router)
# Admin routes (notifications router MUST be before tickets router to avoid route conflict)
router.include_router(admin_ticket_notifications_router)
router.include_router(admin_tickets_router)
router.include_router(admin_settings_router)
router.include_router(admin_apps_router)
router.include_router(admin_wheel_router)
router.include_router(admin_tariffs_router)
router.include_router(admin_servers_router)
router.include_router(admin_stats_router)
router.include_router(admin_ban_system_router)
router.include_router(admin_broadcasts_router)
router.include_router(admin_promocodes_router)
router.include_router(admin_promo_groups_router)
router.include_router(admin_campaigns_router)
router.include_router(admin_users_router)
router.include_router(admin_payment_methods_router)
router.include_router(admin_payments_router)
router.include_router(admin_promo_offers_router)
router.include_router(admin_remnawave_router)
router.include_router(admin_email_templates_router)
router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
# WebSocket route
router.include_router(websocket_router)
__all__ = ['router']
-579
View File
@@ -1,579 +0,0 @@
"""Admin routes for managing VPN applications in app-config.json."""
import json
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.remnawave_service import RemnaWaveService
from app.services.system_settings_service import bot_configuration_service
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
# ============ Schemas ============
class LocalizedText(BaseModel):
"""Localized text for multiple languages."""
en: str = ''
ru: str = ''
zh: str | None = ''
fa: str | None = ''
class AppButton(BaseModel):
"""Button with link and localized text."""
buttonLink: str
buttonText: LocalizedText
class AppStep(BaseModel):
"""Step with description and optional buttons/title."""
description: LocalizedText
buttons: list[AppButton] | None = None
title: LocalizedText | None = None
class AppDefinition(BaseModel):
"""VPN application definition."""
id: str
name: str
isFeatured: bool = False
urlScheme: str
isNeedBase64Encoding: bool | None = None
installationStep: AppStep
addSubscriptionStep: AppStep
connectAndUseStep: AppStep
additionalBeforeAddSubscriptionStep: AppStep | None = None
additionalAfterAddSubscriptionStep: AppStep | None = None
class PlatformApps(BaseModel):
"""Apps for a specific platform."""
platform: str
apps: list[AppDefinition]
class AppConfigBranding(BaseModel):
"""Branding configuration."""
name: str
logoUrl: str
supportUrl: str
class AppConfigConfig(BaseModel):
"""Top-level config section."""
additionalLocales: list[str]
branding: AppConfigBranding
class AppConfigResponse(BaseModel):
"""Full app config response."""
config: AppConfigConfig
platforms: dict[str, list[AppDefinition]]
class CreateAppRequest(BaseModel):
"""Request to create a new app."""
platform: str
app: AppDefinition
class UpdateAppRequest(BaseModel):
"""Request to update an app."""
app: AppDefinition
class ReorderAppsRequest(BaseModel):
"""Request to reorder apps in a platform."""
app_ids: list[str]
class UpdateBrandingRequest(BaseModel):
"""Request to update branding."""
branding: AppConfigBranding
# ============ Helpers ============
def _get_config_path() -> Path:
"""Get path to app-config.json."""
return Path(settings.get_app_config_path())
def _load_config() -> dict:
"""Load app config from file."""
config_path = _get_config_path()
if not config_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'App config file not found: {config_path}',
)
try:
with open(config_path, encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to parse app config: {e}',
)
def _save_config(config: dict) -> None:
"""Save app config to file."""
config_path = _get_config_path()
try:
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=2, ensure_ascii=False)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to save app config: {e}',
)
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
# ============ Routes ============
@router.get('', response_model=AppConfigResponse)
async def get_app_config(
admin: User = Depends(get_current_admin_user),
):
"""Get full app configuration."""
config = _load_config()
return config
@router.get('/platforms', response_model=list[str])
async def get_platforms(
admin: User = Depends(get_current_admin_user),
):
"""Get list of available platforms."""
return VALID_PLATFORMS
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
async def get_platform_apps(
platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Get apps for a specific platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
)
config = _load_config()
platforms = config.get('platforms', {})
return platforms.get(platform, [])
@router.post('/platforms/{platform}', response_model=AppDefinition)
async def create_app(
platform: str,
request: CreateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Create a new app for a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
if platform not in platforms:
platforms[platform] = []
# Check if app with same ID already exists
existing_ids = [app.get('id') for app in platforms[platform]]
if request.app.id in existing_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App with ID '{request.app.id}' already exists in {platform}",
)
# Add new app
app_dict = request.app.model_dump(exclude_none=True)
platforms[platform].append(app_dict)
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
return request.app
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
async def update_app(
platform: str,
app_id: str,
request: UpdateAppRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update an existing app."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and update app
app_index = None
for i, app in enumerate(apps):
if app.get('id') == app_id:
app_index = i
break
if app_index is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Update app
app_dict = request.app.model_dump(exclude_none=True)
apps[app_index] = app_dict
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
return request.app
@router.delete('/platforms/{platform}/{app_id}')
async def delete_app(
platform: str,
app_id: str,
admin: User = Depends(get_current_admin_user),
):
"""Delete an app from a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Find and remove app
original_length = len(apps)
apps = [app for app in apps if app.get('id') != app_id]
if len(apps) == original_length:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
platforms[platform] = apps
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
return {'status': 'deleted', 'app_id': app_id}
@router.post('/platforms/{platform}/reorder')
async def reorder_apps(
platform: str,
request: ReorderAppsRequest,
admin: User = Depends(get_current_admin_user),
):
"""Reorder apps in a platform."""
if platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid platform: {platform}',
)
config = _load_config()
platforms = config.get('platforms', {})
apps = platforms.get(platform, [])
# Create a map of apps by ID
apps_map = {app.get('id'): app for app in apps}
# Verify all IDs exist
for app_id in request.app_ids:
if app_id not in apps_map:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Reorder apps
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
for app in apps:
if app.get('id') not in request.app_ids:
reordered_apps.append(app)
platforms[platform] = reordered_apps
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
return {'status': 'reordered', 'order': request.app_ids}
@router.put('/branding', response_model=AppConfigBranding)
async def update_branding(
request: UpdateBrandingRequest,
admin: User = Depends(get_current_admin_user),
):
"""Update branding configuration."""
config = _load_config()
if 'config' not in config:
config['config'] = {}
config['config']['branding'] = request.branding.model_dump()
_save_config(config)
logger.info(f'Admin {admin.id} updated branding')
return request.branding
@router.get('/branding', response_model=AppConfigBranding)
async def get_branding(
admin: User = Depends(get_current_admin_user),
):
"""Get branding configuration."""
config = _load_config()
branding = config.get('config', {}).get('branding', {})
return branding
@router.post('/platforms/{platform}/copy/{app_id}')
async def copy_app_to_platform(
platform: str,
app_id: str,
target_platform: str,
admin: User = Depends(get_current_admin_user),
):
"""Copy an app from one platform to another."""
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid platform(s)',
)
config = _load_config()
platforms = config.get('platforms', {})
source_apps = platforms.get(platform, [])
# Find source app
source_app = None
for app in source_apps:
if app.get('id') == app_id:
source_app = app.copy()
break
if not source_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"App '{app_id}' not found in platform '{platform}'",
)
# Generate new ID for copied app
import time
new_id = f'{app_id}-copy-{int(time.time())}'
source_app['id'] = new_id
# Add to target platform
if target_platform not in platforms:
platforms[target_platform] = []
platforms[target_platform].append(source_app)
config['platforms'] = platforms
_save_config(config)
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
# ============ RemnaWave Config Routes ============
class RemnaWaveConfigStatus(BaseModel):
"""Status of RemnaWave config integration."""
enabled: bool
config_uuid: str | None = None
class UpdateRemnaWaveUuidRequest(BaseModel):
"""Request to update RemnaWave config UUID."""
uuid: str | None = None
def _get_remnawave_config_uuid() -> str | None:
"""Get RemnaWave config UUID from system settings or env."""
try:
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
except Exception:
return settings.CABINET_REMNA_SUB_CONFIG
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
async def get_remnawave_config_status(
admin: User = Depends(get_current_admin_user),
):
"""Get RemnaWave config integration status."""
config_uuid = _get_remnawave_config_uuid()
return RemnaWaveConfigStatus(
enabled=bool(config_uuid),
config_uuid=config_uuid,
)
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
async def set_remnawave_config_uuid(
request: UpdateRemnaWaveUuidRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Set RemnaWave subscription config UUID."""
uuid_value = request.uuid.strip() if request.uuid else None
# Validate UUID format if provided
if uuid_value:
import re
uuid_pattern = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
if not uuid_pattern.match(uuid_value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid UUID format',
)
try:
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
await db.commit()
logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'")
except Exception as e:
logger.error(f'Error saving RemnaWave config UUID: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to save configuration',
)
return RemnaWaveConfigStatus(
enabled=bool(uuid_value),
config_uuid=uuid_value,
)
@router.get('/remnawave/config')
async def get_remnawave_subscription_config(
admin: User = Depends(get_current_admin_user),
):
"""
Fetch subscription page config from RemnaWave panel.
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
"""
config_uuid = _get_remnawave_config_uuid()
if not config_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='CABINET_REMNA_SUB_CONFIG is not configured',
)
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
config = await api.get_subscription_page_config(config_uuid)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
)
# Return the raw config data from RemnaWave
return {
'uuid': config.uuid,
'name': config.name,
'view_position': config.view_position,
'config': config.config,
}
except HTTPException:
raise
except Exception as e:
logger.error(f'Error fetching RemnaWave config: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch config from RemnaWave: {e!s}',
)
@router.get('/remnawave/configs')
async def list_remnawave_subscription_configs(
admin: User = Depends(get_current_admin_user),
):
"""List available subscription page configs from RemnaWave panel."""
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
configs = await api.get_subscription_page_configs()
return [
{
'uuid': c.uuid,
'name': c.name,
'view_position': c.view_position,
}
for c in configs
]
except Exception as e:
logger.error(f'Error listing RemnaWave configs: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
)
File diff suppressed because it is too large Load Diff
-726
View File
@@ -1,726 +0,0 @@
"""Admin routes for broadcasts in cabinet."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import BroadcastHistory, Subscription, SubscriptionStatus, Tariff, User
from app.handlers.admin.messages import get_target_users_count
from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS
from app.services.broadcast_service import (
BroadcastConfig,
BroadcastMediaConfig,
EmailBroadcastConfig,
broadcast_service,
email_broadcast_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.broadcasts import (
BroadcastButton,
BroadcastButtonsResponse,
BroadcastCreateRequest,
BroadcastFilter,
BroadcastFiltersResponse,
BroadcastListResponse,
BroadcastPreviewRequest,
BroadcastPreviewResponse,
BroadcastResponse,
BroadcastTariffsResponse,
CombinedBroadcastCreateRequest,
EmailFilterItem,
EmailFiltersResponse,
EmailPreviewRequest,
EmailPreviewResponse,
TariffFilter,
TariffForBroadcast,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/broadcasts', tags=['Cabinet Admin Broadcasts'])
# ============ Filter Labels ============
FILTER_LABELS = {
'all': 'Все пользователи',
'active': 'Активные подписки',
'trial': 'Триальные',
'no': 'Без подписки',
'expiring': 'Истекают (3 дня)',
'expired': 'Истекшие',
'zero': 'Нулевой трафик',
'active_zero': 'Активные с нулевым трафиком',
'trial_zero': 'Триальные с нулевым трафиком',
}
FILTER_GROUPS = {
'all': 'basic',
'active': 'subscription',
'trial': 'subscription',
'no': 'subscription',
'expiring': 'subscription',
'expired': 'subscription',
'zero': 'traffic',
'active_zero': 'traffic',
'trial_zero': 'traffic',
}
CUSTOM_FILTER_LABELS = {
'custom_today': 'Регистрация сегодня',
'custom_week': 'Регистрация за неделю',
'custom_month': 'Регистрация за месяц',
'custom_active_today': 'Активны сегодня',
'custom_inactive_week': 'Неактивны 7+ дней',
'custom_inactive_month': 'Неактивны 30+ дней',
'custom_referrals': 'Пришли по рефералу',
'custom_direct': 'Прямая регистрация',
}
CUSTOM_FILTER_GROUPS = {
'custom_today': 'registration',
'custom_week': 'registration',
'custom_month': 'registration',
'custom_active_today': 'activity',
'custom_inactive_week': 'activity',
'custom_inactive_month': 'activity',
'custom_referrals': 'source',
'custom_direct': 'source',
}
# ============ Email Filter Labels ============
EMAIL_FILTER_LABELS = {
'all_email': 'Все с email',
'email_only': 'Только email-регистрация',
'telegram_with_email': 'Telegram с email',
'active_email': 'С активной подпиской',
'expired_email': 'С истекшей подпиской',
}
EMAIL_FILTER_GROUPS = {
'all_email': 'basic',
'email_only': 'auth_type',
'telegram_with_email': 'auth_type',
'active_email': 'subscription',
'expired_email': 'subscription',
}
# ============ Helper Functions ============
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
"""Serialize broadcast to response model."""
progress = 0.0
if broadcast.total_count > 0:
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
return BroadcastResponse(
id=broadcast.id,
target_type=broadcast.target_type,
message_text=broadcast.message_text,
has_media=broadcast.has_media,
media_type=broadcast.media_type,
media_file_id=broadcast.media_file_id,
media_caption=broadcast.media_caption,
total_count=broadcast.total_count,
sent_count=broadcast.sent_count,
failed_count=broadcast.failed_count,
status=broadcast.status,
admin_id=broadcast.admin_id,
admin_name=broadcast.admin_name,
created_at=broadcast.created_at,
completed_at=broadcast.completed_at,
progress_percent=progress,
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
email_subject=getattr(broadcast, 'email_subject', None),
email_html_content=getattr(broadcast, 'email_html_content', None),
)
async def _get_email_filter_count(db: AsyncSession, target: str) -> int:
"""Get count of email users matching the filter."""
base_conditions = [
User.email.isnot(None),
User.email_verified == True,
User.status == 'active',
]
if target == 'all_email':
query = select(func.count(User.id)).where(*base_conditions)
elif target == 'email_only':
query = select(func.count(User.id)).where(
*base_conditions,
User.auth_type == 'email',
)
elif target == 'telegram_with_email':
query = select(func.count(User.id)).where(
*base_conditions,
User.auth_type == 'telegram',
User.telegram_id.isnot(None),
)
elif target == 'active_email':
query = (
select(func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
elif target == 'expired_email':
query = (
select(func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status.in_(
[
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
]
),
)
)
else:
return 0
result = await db.execute(query)
return result.scalar() or 0
def _validate_email_target(target: str) -> bool:
"""Validate email target filter."""
return target in EMAIL_FILTER_LABELS
async def _get_tariff_user_counts(db: AsyncSession) -> dict:
"""Get count of active users per tariff."""
result = await db.execute(
select(Subscription.tariff_id, func.count(func.distinct(Subscription.user_id)).label('count'))
.join(User, User.id == Subscription.user_id)
.where(
User.status == 'active',
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
.group_by(Subscription.tariff_id)
)
return {row.tariff_id: row.count for row in result.all()}
def _validate_target(target: str, tariff_ids: set) -> bool:
"""Validate target value."""
if target in FILTER_LABELS:
return True
if target in CUSTOM_FILTER_LABELS:
return True
if target.startswith('tariff_'):
try:
tariff_id = int(target.split('_')[1])
return tariff_id in tariff_ids
except (ValueError, IndexError):
return False
return False
def _validate_buttons(buttons: list[str]) -> bool:
"""Validate button keys."""
return all(button in BROADCAST_BUTTONS for button in buttons)
# ============ Endpoints ============
@router.get('/filters', response_model=BroadcastFiltersResponse)
async def get_filters(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastFiltersResponse:
"""Get all available filters with user counts."""
# Basic filters
filters = []
for key, label in FILTER_LABELS.items():
try:
count = await get_target_users_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for filter {key}: {e}')
count = 0
filters.append(
BroadcastFilter(
key=key,
label=label,
count=count,
group=FILTER_GROUPS.get(key),
)
)
# Custom filters
custom_filters = []
for key, label in CUSTOM_FILTER_LABELS.items():
try:
count = await get_target_users_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for custom filter {key}: {e}')
count = 0
custom_filters.append(
BroadcastFilter(
key=key,
label=label,
count=count,
group=CUSTOM_FILTER_GROUPS.get(key),
)
)
# Tariff filters
tariff_counts = await _get_tariff_user_counts(db)
result = await db.execute(select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name))
tariffs = result.scalars().all()
tariff_filters = []
for tariff in tariffs:
tariff_filters.append(
TariffFilter(
key=f'tariff_{tariff.id}',
label=tariff.name,
tariff_id=tariff.id,
count=tariff_counts.get(tariff.id, 0),
)
)
return BroadcastFiltersResponse(
filters=filters,
tariff_filters=tariff_filters,
custom_filters=custom_filters,
)
@router.get('/tariffs', response_model=BroadcastTariffsResponse)
async def get_tariffs(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastTariffsResponse:
"""Get tariffs for broadcast filtering."""
tariff_counts = await _get_tariff_user_counts(db)
result = await db.execute(select(Tariff).where(Tariff.is_active == True).order_by(Tariff.name))
tariffs = result.scalars().all()
return BroadcastTariffsResponse(
tariffs=[
TariffForBroadcast(
id=t.id,
name=t.name,
filter_key=f'tariff_{t.id}',
active_users_count=tariff_counts.get(t.id, 0),
)
for t in tariffs
]
)
@router.get('/buttons', response_model=BroadcastButtonsResponse)
async def get_buttons(
admin: User = Depends(get_current_admin_user),
) -> BroadcastButtonsResponse:
"""Get available buttons for broadcasts."""
default_buttons = set(DEFAULT_BROADCAST_BUTTONS)
buttons = []
for key, config in BROADCAST_BUTTONS.items():
buttons.append(
BroadcastButton(
key=key,
label=config.get('default_text', key),
default=key in default_buttons,
)
)
return BroadcastButtonsResponse(buttons=buttons)
@router.post('/preview', response_model=BroadcastPreviewResponse)
async def preview_broadcast(
request: BroadcastPreviewRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastPreviewResponse:
"""Preview broadcast recipients count."""
# Get tariff IDs for validation
result = await db.execute(select(Tariff.id))
tariff_ids = {row[0] for row in result.all()}
if not _validate_target(request.target, tariff_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid target: {request.target}',
)
try:
count = await get_target_users_count(db, request.target)
except Exception as e:
logger.error(f'Failed to get count for target {request.target}: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to count recipients',
)
return BroadcastPreviewResponse(target=request.target, count=count)
@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_broadcast(
request: BroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a broadcast."""
# Validate target
result = await db.execute(select(Tariff.id))
tariff_ids = {row[0] for row in result.all()}
if not _validate_target(request.target, tariff_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid target: {request.target}',
)
# Validate buttons
if not _validate_buttons(request.selected_buttons):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid button key',
)
message_text = request.message_text.strip()
if not message_text:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Message text must not be empty',
)
media_payload = request.media
# Create broadcast record
broadcast = BroadcastHistory(
target_type=request.target,
message_text=message_text,
has_media=media_payload is not None,
media_type=media_payload.type if media_payload else None,
media_file_id=media_payload.file_id if media_payload else None,
media_caption=media_payload.caption if media_payload else None,
total_count=0,
sent_count=0,
failed_count=0,
status='queued',
admin_id=admin.id,
admin_name=admin.username or f'Admin #{admin.id}',
)
db.add(broadcast)
await db.commit()
await db.refresh(broadcast)
# Prepare media config
media_config = None
if media_payload:
media_config = BroadcastMediaConfig(
type=media_payload.type,
file_id=media_payload.file_id,
caption=media_payload.caption or message_text,
)
# Create broadcast config
config = BroadcastConfig(
target=request.target,
message_text=message_text,
selected_buttons=request.selected_buttons,
media=media_config,
initiator_name=admin.username or f'Admin #{admin.id}',
)
# Start broadcast
await broadcast_service.start_broadcast(broadcast.id, config)
await db.refresh(broadcast)
logger.info(f"Admin {admin.id} created broadcast {broadcast.id} for target '{request.target}'")
return _serialize_broadcast(broadcast)
@router.get('', response_model=BroadcastListResponse)
async def list_broadcasts(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
) -> BroadcastListResponse:
"""Get list of broadcasts with pagination."""
total = await db.scalar(select(func.count(BroadcastHistory.id))) or 0
result = await db.execute(
select(BroadcastHistory).order_by(BroadcastHistory.created_at.desc()).offset(offset).limit(limit)
)
broadcasts = result.scalars().all()
return BroadcastListResponse(
items=[_serialize_broadcast(b) for b in broadcasts],
total=int(total),
limit=limit,
offset=offset,
)
# ============ Email Broadcast Endpoints ============
@router.get('/email-filters', response_model=EmailFiltersResponse)
async def get_email_filters(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailFiltersResponse:
"""Get all available email filters with user counts."""
filters = []
total_with_email = 0
for key, label in EMAIL_FILTER_LABELS.items():
try:
count = await _get_email_filter_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for email filter {key}: {e}')
count = 0
filters.append(
EmailFilterItem(
key=key,
label=label,
count=count,
group=EMAIL_FILTER_GROUPS.get(key),
)
)
# Track total with email (all_email filter)
if key == 'all_email':
total_with_email = count
return EmailFiltersResponse(
filters=filters,
total_with_email=total_with_email,
)
@router.post('/email-preview', response_model=EmailPreviewResponse)
async def preview_email_broadcast(
request: EmailPreviewRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailPreviewResponse:
"""Preview email broadcast recipients count."""
if not _validate_email_target(request.target):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid email target: {request.target}',
)
try:
count = await _get_email_filter_count(db, request.target)
except Exception as e:
logger.error(f'Failed to get email count for target {request.target}: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to count email recipients',
)
return EmailPreviewResponse(target=request.target, count=count)
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_combined_broadcast(
request: CombinedBroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a combined broadcast (telegram/email/both)."""
# Get tariff IDs for target validation
result = await db.execute(select(Tariff.id))
tariff_ids = {row[0] for row in result.all()}
admin_name = admin.username or f'Admin #{admin.id}'
# Validate based on channel
if request.channel in ('telegram', 'both'):
# Validate telegram target
if not _validate_target(request.target, tariff_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid target: {request.target}',
)
# Validate telegram message
if not request.message_text or not request.message_text.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Message text is required for Telegram broadcast',
)
# Validate buttons
if not _validate_buttons(request.selected_buttons):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid button key',
)
if request.channel in ('email', 'both'):
# For email channel, target must be email filter or we use telegram target for 'both'
if request.channel == 'email' and not _validate_email_target(request.target):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid email target: {request.target}',
)
# Validate email fields
if not request.email_subject or not request.email_subject.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Email subject is required for email broadcast',
)
if not request.email_html_content or not request.email_html_content.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Email HTML content is required for email broadcast',
)
media_payload = request.media
# Create broadcast record
broadcast = BroadcastHistory(
target_type=request.target,
message_text=request.message_text.strip() if request.message_text else None,
has_media=media_payload is not None,
media_type=media_payload.type if media_payload else None,
media_file_id=media_payload.file_id if media_payload else None,
media_caption=media_payload.caption if media_payload else None,
total_count=0,
sent_count=0,
failed_count=0,
status='queued',
admin_id=admin.id,
admin_name=admin_name,
channel=request.channel,
email_subject=request.email_subject.strip() if request.email_subject else None,
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
)
db.add(broadcast)
await db.commit()
await db.refresh(broadcast)
# Start broadcasts based on channel
if request.channel in ('telegram', 'both'):
# Prepare media config
media_config = None
if media_payload:
media_config = BroadcastMediaConfig(
type=media_payload.type,
file_id=media_payload.file_id,
caption=media_payload.caption or request.message_text,
)
# Create telegram broadcast config
telegram_config = BroadcastConfig(
target=request.target,
message_text=request.message_text.strip(),
selected_buttons=request.selected_buttons,
media=media_config,
initiator_name=admin_name,
)
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
if request.channel in ('email', 'both'):
# For 'both' channel, we use 'all_email' as default email target
# since telegram target won't match email filters
email_target = request.target if request.channel == 'email' else 'all_email'
# Create email broadcast config
email_config = EmailBroadcastConfig(
target=email_target,
email_subject=request.email_subject.strip(),
email_html_content=request.email_html_content.strip(),
initiator_name=admin_name,
)
await email_broadcast_service.start_broadcast(broadcast.id, email_config)
await db.refresh(broadcast)
logger.info(f"Admin {admin.id} created {request.channel} broadcast {broadcast.id} for target '{request.target}'")
return _serialize_broadcast(broadcast)
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
async def get_broadcast(
broadcast_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Get broadcast details."""
broadcast = await db.get(BroadcastHistory, broadcast_id)
if not broadcast:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Broadcast not found',
)
return _serialize_broadcast(broadcast)
@router.post('/{broadcast_id}/stop', response_model=BroadcastResponse)
async def stop_broadcast(
broadcast_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Stop a running broadcast (telegram or email)."""
broadcast = await db.get(BroadcastHistory, broadcast_id)
if not broadcast:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Broadcast not found',
)
if broadcast.status not in {'queued', 'in_progress', 'cancelling'}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Broadcast is not running',
)
# Try to stop both telegram and email broadcasts (one or both may be running)
channel = getattr(broadcast, 'channel', 'telegram') or 'telegram'
is_running = False
if channel in ('telegram', 'both'):
is_running = await broadcast_service.request_stop(broadcast_id) or is_running
if channel in ('email', 'both'):
is_running = await email_broadcast_service.request_stop(broadcast_id) or is_running
if is_running:
broadcast.status = 'cancelling'
else:
broadcast.status = 'cancelled'
broadcast.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(broadcast)
logger.info(f'Admin {admin.id} stopped broadcast {broadcast_id}')
return _serialize_broadcast(broadcast)
-507
View File
@@ -1,507 +0,0 @@
"""Admin routes for managing advertising campaigns in cabinet."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.campaign import (
create_campaign,
delete_campaign,
get_campaign_by_id,
get_campaign_by_start_parameter,
get_campaign_statistics,
get_campaigns_count,
get_campaigns_list,
get_campaigns_overview,
update_campaign,
)
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import get_all_tariffs
from app.database.models import (
AdvertisingCampaignRegistration,
Subscription,
Tariff,
User,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.campaigns import (
CampaignCreateRequest,
CampaignDetailResponse,
CampaignListItem,
CampaignListResponse,
CampaignRegistrationItem,
CampaignRegistrationsResponse,
CampaignsOverviewResponse,
CampaignStatisticsResponse,
CampaignToggleResponse,
CampaignUpdateRequest,
ServerSquadInfo,
TariffInfo,
)
from ..schemas.tariffs import TariffListItem
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns'])
def _get_deep_link(start_parameter: str) -> str:
"""Generate deep link for campaign."""
bot_username = settings.get_bot_username()
if bot_username:
return f'https://t.me/{bot_username}?start={start_parameter}'
return f'?start={start_parameter}'
@router.get('/overview', response_model=CampaignsOverviewResponse)
async def get_overview(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get campaigns overview statistics."""
overview = await get_campaigns_overview(db)
# Count tariff bonuses
tariff_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.bonus_type == 'tariff'
)
)
tariff_count = tariff_result.scalar() or 0
return CampaignsOverviewResponse(
total=overview['total'],
active=overview['active'],
inactive=overview['inactive'],
total_registrations=overview['registrations'],
total_balance_issued_kopeks=overview['balance_total'],
total_balance_issued_rubles=overview['balance_total'] / 100,
total_subscription_issued=overview['subscription_total'],
total_tariff_issued=tariff_count,
)
@router.get('/available-servers', response_model=list[ServerSquadInfo])
async def get_available_servers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available server squads for campaign subscription bonus."""
servers, _ = await get_all_server_squads(db, available_only=False)
return [
ServerSquadInfo(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
country_code=server.country_code,
)
for server in servers
]
@router.get('/available-tariffs', response_model=list[TariffListItem])
async def get_available_tariffs(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available tariffs for campaign tariff bonus."""
tariffs = await get_all_tariffs(db, include_inactive=False)
return [
TariffListItem(
id=tariff.id,
name=tariff.name,
description=tariff.description,
is_active=tariff.is_active,
is_trial_available=tariff.is_trial_available,
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks or 0,
allow_traffic_topup=tariff.allow_traffic_topup,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
display_order=tariff.display_order,
servers_count=len(tariff.allowed_squads or []),
subscriptions_count=0,
created_at=tariff.created_at,
)
for tariff in tariffs
]
@router.get('', response_model=CampaignListResponse)
async def list_campaigns(
include_inactive: bool = True,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all campaigns."""
campaigns = await get_campaigns_list(db, offset=offset, limit=limit, include_inactive=include_inactive)
total = await get_campaigns_count(db)
items = []
for campaign in campaigns:
# Get quick stats
stats = await get_campaign_statistics(db, campaign.id)
items.append(
CampaignListItem(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
registrations_count=stats['registrations'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
conversion_rate=stats['conversion_rate'],
created_at=campaign.created_at,
)
)
return CampaignListResponse(campaigns=items, total=total)
@router.get('/{campaign_id}', response_model=CampaignDetailResponse)
async def get_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed campaign info."""
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
)
tariff_info = None
if campaign.tariff:
tariff_info = TariffInfo(
id=campaign.tariff.id,
name=campaign.tariff.name,
)
return CampaignDetailResponse(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
balance_bonus_kopeks=campaign.balance_bonus_kopeks or 0,
balance_bonus_rubles=(campaign.balance_bonus_kopeks or 0) / 100,
subscription_duration_days=campaign.subscription_duration_days,
subscription_traffic_gb=campaign.subscription_traffic_gb,
subscription_device_limit=campaign.subscription_device_limit,
subscription_squads=campaign.subscription_squads or [],
tariff_id=campaign.tariff_id,
tariff_duration_days=campaign.tariff_duration_days,
tariff=tariff_info,
created_by=campaign.created_by,
created_at=campaign.created_at,
updated_at=campaign.updated_at,
deep_link=_get_deep_link(campaign.start_parameter),
)
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
async def get_campaign_stats(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed campaign statistics."""
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
)
stats = await get_campaign_statistics(db, campaign_id)
return CampaignStatisticsResponse(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
is_active=campaign.is_active,
registrations=stats['registrations'],
balance_issued_kopeks=stats['balance_issued'],
balance_issued_rubles=stats['balance_issued'] / 100,
subscription_issued=stats['subscription_issued'],
last_registration=stats['last_registration'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
total_revenue_rubles=stats['total_revenue_kopeks'] / 100,
avg_revenue_per_user_kopeks=stats['avg_revenue_per_user_kopeks'],
avg_revenue_per_user_rubles=stats['avg_revenue_per_user_kopeks'] / 100,
avg_first_payment_kopeks=stats['avg_first_payment_kopeks'],
avg_first_payment_rubles=stats['avg_first_payment_kopeks'] / 100,
trial_users_count=stats['trial_users_count'],
active_trials_count=stats['active_trials_count'],
conversion_count=stats['conversion_count'],
paid_users_count=stats['paid_users_count'],
conversion_rate=stats['conversion_rate'],
trial_conversion_rate=stats['trial_conversion_rate'],
deep_link=_get_deep_link(campaign.start_parameter),
)
@router.get('/{campaign_id}/registrations', response_model=CampaignRegistrationsResponse)
async def get_campaign_registrations(
campaign_id: int,
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of users registered through campaign."""
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
)
offset = (page - 1) * per_page
# Get registrations with user info
result = await db.execute(
select(AdvertisingCampaignRegistration, User)
.join(User, AdvertisingCampaignRegistration.user_id == User.id)
.where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
.order_by(AdvertisingCampaignRegistration.created_at.desc())
.offset(offset)
.limit(per_page)
)
rows = result.all()
# Count total
count_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.campaign_id == campaign_id
)
)
total = count_result.scalar() or 0
items = []
for reg, user in rows:
# Check if user has subscription
sub_result = await db.execute(
select(Subscription)
.where(
Subscription.user_id == user.id,
Subscription.status == 'active',
)
.limit(1)
)
has_sub = sub_result.scalar_one_or_none() is not None
items.append(
CampaignRegistrationItem(
id=reg.id,
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
bonus_type=reg.bonus_type,
balance_bonus_kopeks=reg.balance_bonus_kopeks or 0,
subscription_duration_days=reg.subscription_duration_days,
tariff_id=reg.tariff_id,
tariff_duration_days=reg.tariff_duration_days,
created_at=reg.created_at,
user_balance_kopeks=user.balance_kopeks or 0,
has_subscription=has_sub,
has_paid=user.has_had_paid_subscription or False,
)
)
return CampaignRegistrationsResponse(
registrations=items,
total=total,
page=page,
per_page=per_page,
)
@router.post('', response_model=CampaignDetailResponse)
async def create_new_campaign(
request: CampaignCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new advertising campaign."""
# Check if start_parameter is unique
existing = await get_campaign_by_start_parameter(db, request.start_parameter)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Campaign with start parameter '{request.start_parameter}' already exists",
)
# Validate tariff exists if tariff bonus type
if request.bonus_type == 'tariff':
if not request.tariff_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tariff ID is required for tariff bonus type',
)
tariff_result = await db.execute(select(Tariff).where(Tariff.id == request.tariff_id))
tariff = tariff_result.scalar_one_or_none()
if not tariff:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tariff not found',
)
campaign = await create_campaign(
db,
name=request.name,
start_parameter=request.start_parameter,
bonus_type=request.bonus_type,
created_by=admin.id,
balance_bonus_kopeks=request.balance_bonus_kopeks,
subscription_duration_days=request.subscription_duration_days,
subscription_traffic_gb=request.subscription_traffic_gb,
subscription_device_limit=request.subscription_device_limit,
subscription_squads=request.subscription_squads,
tariff_id=request.tariff_id,
tariff_duration_days=request.tariff_duration_days,
is_active=request.is_active,
)
# Reload to get tariff relationship
campaign = await get_campaign_by_id(db, campaign.id)
logger.info(f'Admin {admin.id} created campaign {campaign.id}: {campaign.name}')
return await get_campaign(campaign.id, admin, db)
@router.put('/{campaign_id}', response_model=CampaignDetailResponse)
async def update_existing_campaign(
campaign_id: int,
request: CampaignUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing campaign."""
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
)
# Check if start_parameter is unique (if changing)
if request.start_parameter and request.start_parameter != campaign.start_parameter:
existing = await get_campaign_by_start_parameter(db, request.start_parameter)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Campaign with start parameter '{request.start_parameter}' already exists",
)
# Validate tariff if changing to tariff bonus type
if request.bonus_type == 'tariff' or (campaign.bonus_type == 'tariff' and request.tariff_id):
tariff_id = request.tariff_id or campaign.tariff_id
if tariff_id:
tariff_result = await db.execute(select(Tariff).where(Tariff.id == tariff_id))
tariff = tariff_result.scalar_one_or_none()
if not tariff:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Tariff not found',
)
# Build updates
updates = {}
if request.name is not None:
updates['name'] = request.name
if request.start_parameter is not None:
updates['start_parameter'] = request.start_parameter
if request.bonus_type is not None:
updates['bonus_type'] = request.bonus_type
if request.is_active is not None:
updates['is_active'] = request.is_active
if request.balance_bonus_kopeks is not None:
updates['balance_bonus_kopeks'] = request.balance_bonus_kopeks
if request.subscription_duration_days is not None:
updates['subscription_duration_days'] = request.subscription_duration_days
if request.subscription_traffic_gb is not None:
updates['subscription_traffic_gb'] = request.subscription_traffic_gb
if request.subscription_device_limit is not None:
updates['subscription_device_limit'] = request.subscription_device_limit
if request.subscription_squads is not None:
updates['subscription_squads'] = request.subscription_squads
if request.tariff_id is not None:
updates['tariff_id'] = request.tariff_id
if request.tariff_duration_days is not None:
updates['tariff_duration_days'] = request.tariff_duration_days
if updates:
await update_campaign(db, campaign, **updates)
logger.info(f'Admin {admin.id} updated campaign {campaign_id}')
return await get_campaign(campaign_id, admin, db)
@router.delete('/{campaign_id}')
async def delete_existing_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a campaign."""
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
)
# Check if campaign has registrations
reg_count = len(campaign.registrations) if campaign.registrations else 0
if reg_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Cannot delete campaign with {reg_count} registrations. Deactivate it instead.',
)
await delete_campaign(db, campaign)
logger.info(f'Admin {admin.id} deleted campaign {campaign_id}: {campaign.name}')
return {'message': 'Campaign deleted successfully'}
@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse)
async def toggle_campaign(
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle campaign active status."""
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Campaign not found',
)
new_status = not campaign.is_active
await update_campaign(db, campaign, is_active=new_status)
status_text = 'activated' if new_status else 'deactivated'
logger.info(f'Admin {admin.id} {status_text} campaign {campaign_id}')
return CampaignToggleResponse(
id=campaign_id,
is_active=new_status,
message=f'Campaign {status_text}',
)
-680
View File
@@ -1,680 +0,0 @@
"""Admin routes for managing email notification templates."""
import asyncio
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..services.email_template_overrides import (
delete_template_override,
get_all_overrides,
get_overrides_for_type,
save_template_override,
)
from ..services.email_templates import EmailNotificationTemplates
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/email-templates', tags=['Admin Email Templates'])
# ============ Template type metadata ============
TEMPLATE_TYPES = [
{
'type': 'balance_topup',
'label': {'ru': 'Пополнение баланса', 'en': 'Balance Top-up', 'zh': '余额充值', 'ua': 'Поповнення балансу'},
'description': {
'ru': 'Уведомление о пополнении баланса',
'en': 'Balance top-up notification',
'zh': '余额充值通知',
'ua': 'Сповіщення про поповнення балансу',
},
'context_vars': ['amount', 'balance'],
},
{
'type': 'balance_change',
'label': {'ru': 'Изменение баланса', 'en': 'Balance Change', 'zh': '余额变动', 'ua': 'Зміна балансу'},
'description': {
'ru': 'Уведомление об изменении баланса',
'en': 'Balance change notification',
'zh': '余额变动通知',
'ua': 'Сповіщення про зміну балансу',
},
'context_vars': ['amount', 'balance'],
},
{
'type': 'subscription_expiring',
'label': {
'ru': 'Подписка истекает',
'en': 'Subscription Expiring',
'zh': '订阅即将到期',
'ua': 'Підписка закінчується',
},
'description': {
'ru': 'Предупреждение об истечении подписки',
'en': 'Subscription expiring warning',
'zh': '订阅即将到期警告',
'ua': 'Попередження про закінчення підписки',
},
'context_vars': ['days_left', 'expires_at'],
},
{
'type': 'subscription_expired',
'label': {
'ru': 'Подписка истекла',
'en': 'Subscription Expired',
'zh': '订阅已到期',
'ua': 'Підписка закінчилась',
},
'description': {
'ru': 'Уведомление об истечении подписки',
'en': 'Subscription expired notification',
'zh': '订阅已到期通知',
'ua': 'Сповіщення про закінчення підписки',
},
'context_vars': [],
},
{
'type': 'subscription_renewed',
'label': {
'ru': 'Подписка продлена',
'en': 'Subscription Renewed',
'zh': '订阅已续期',
'ua': 'Підписка продовжена',
},
'description': {
'ru': 'Уведомление о продлении подписки',
'en': 'Subscription renewed notification',
'zh': '订阅已续期通知',
'ua': 'Сповіщення про продовження підписки',
},
'context_vars': ['new_end_date', 'tariff_name'],
},
{
'type': 'subscription_activated',
'label': {
'ru': 'Подписка активирована',
'en': 'Subscription Activated',
'zh': '订阅已激活',
'ua': 'Підписка активована',
},
'description': {
'ru': 'Уведомление об активации подписки',
'en': 'Subscription activated notification',
'zh': '订阅已激活通知',
'ua': 'Сповіщення про активацію підписки',
},
'context_vars': ['tariff_name', 'end_date'],
},
{
'type': 'autopay_success',
'label': {
'ru': 'Автоплатёж успешен',
'en': 'Autopay Success',
'zh': '自动续费成功',
'ua': 'Автоплатіж успішний',
},
'description': {
'ru': 'Уведомление об успешном автоплатеже',
'en': 'Autopay success notification',
'zh': '自动续费成功通知',
'ua': 'Сповіщення про успішний автоплатіж',
},
'context_vars': ['amount', 'balance', 'new_end_date'],
},
{
'type': 'autopay_failed',
'label': {
'ru': 'Автоплатёж не удался',
'en': 'Autopay Failed',
'zh': '自动续费失败',
'ua': 'Автоплатіж не вдався',
},
'description': {
'ru': 'Уведомление о неудачном автоплатеже',
'en': 'Autopay failed notification',
'zh': '自动续费失败通知',
'ua': 'Сповіщення про невдалий автоплатіж',
},
'context_vars': ['reason'],
},
{
'type': 'autopay_insufficient_funds',
'label': {
'ru': 'Недостаточно средств (автоплатёж)',
'en': 'Insufficient Funds (Autopay)',
'zh': '余额不足(自动续费)',
'ua': 'Недостатньо коштів (автоплатіж)',
},
'description': {
'ru': 'Уведомление о нехватке средств для автоплатежа',
'en': 'Insufficient funds for autopay notification',
'zh': '自动续费余额不足通知',
'ua': 'Сповіщення про нестачу коштів для автоплатежу',
},
'context_vars': ['required_amount', 'balance'],
},
{
'type': 'daily_debit',
'label': {'ru': 'Суточное списание', 'en': 'Daily Debit', 'zh': '每日扣费', 'ua': 'Добове списання'},
'description': {
'ru': 'Уведомление о суточном списании',
'en': 'Daily debit notification',
'zh': '每日扣费通知',
'ua': 'Сповіщення про добове списання',
},
'context_vars': ['amount', 'balance'],
},
{
'type': 'daily_insufficient_funds',
'label': {
'ru': 'Недостаточно средств (суточное)',
'en': 'Insufficient Funds (Daily)',
'zh': '余额不足(每日)',
'ua': 'Недостатньо коштів (добове)',
},
'description': {
'ru': 'Уведомление о нехватке средств для суточного списания',
'en': 'Insufficient funds for daily debit',
'zh': '每日扣费余额不足通知',
'ua': 'Сповіщення про нестачу коштів для добового списання',
},
'context_vars': ['required_amount', 'balance'],
},
{
'type': 'ban_notification',
'label': {'ru': 'Блокировка аккаунта', 'en': 'Account Banned', 'zh': '账户被封禁', 'ua': 'Блокування акаунту'},
'description': {
'ru': 'Уведомление о блокировке аккаунта',
'en': 'Account banned notification',
'zh': '账户被封禁通知',
'ua': 'Сповіщення про блокування акаунту',
},
'context_vars': ['reason'],
},
{
'type': 'unban_notification',
'label': {
'ru': 'Разблокировка аккаунта',
'en': 'Account Unbanned',
'zh': '账户已解封',
'ua': 'Розблокування акаунту',
},
'description': {
'ru': 'Уведомление о разблокировке аккаунта',
'en': 'Account unbanned notification',
'zh': '账户已解封通知',
'ua': 'Сповіщення про розблокування акаунту',
},
'context_vars': [],
},
{
'type': 'warning_notification',
'label': {'ru': 'Предупреждение', 'en': 'Warning', 'zh': '警告', 'ua': 'Попередження'},
'description': {
'ru': 'Предупреждение пользователю',
'en': 'Warning notification',
'zh': '警告通知',
'ua': 'Попередження користувачу',
},
'context_vars': ['message'],
},
{
'type': 'referral_bonus',
'label': {'ru': 'Реферальный бонус', 'en': 'Referral Bonus', 'zh': '推荐奖励', 'ua': 'Реферальний бонус'},
'description': {
'ru': 'Уведомление о начислении реферального бонуса',
'en': 'Referral bonus notification',
'zh': '推荐奖励通知',
'ua': 'Сповіщення про нарахування реферального бонусу',
},
'context_vars': ['amount', 'referral_name'],
},
{
'type': 'referral_registered',
'label': {'ru': 'Новый реферал', 'en': 'New Referral', 'zh': '新推荐用户', 'ua': 'Новий реферал'},
'description': {
'ru': 'Уведомление о регистрации реферала',
'en': 'New referral registered notification',
'zh': '新推荐用户注册通知',
'ua': 'Сповіщення про реєстрацію реферала',
},
'context_vars': ['referral_name'],
},
{
'type': 'traffic_reset',
'label': {'ru': 'Сброс трафика', 'en': 'Traffic Reset', 'zh': '流量重置', 'ua': 'Скидання трафіку'},
'description': {
'ru': 'Уведомление о сбросе трафика',
'en': 'Traffic reset notification',
'zh': '流量重置通知',
'ua': 'Сповіщення про скидання трафіку',
},
'context_vars': ['traffic_limit'],
},
{
'type': 'payment_received',
'label': {'ru': 'Платёж получен', 'en': 'Payment Received', 'zh': '收到付款', 'ua': 'Платіж отримано'},
'description': {
'ru': 'Уведомление о получении платежа',
'en': 'Payment received notification',
'zh': '收到付款通知',
'ua': 'Сповіщення про отримання платежу',
},
'context_vars': ['amount', 'payment_method'],
},
{
'type': 'email_verification',
'label': {
'ru': 'Подтверждение email',
'en': 'Email Verification',
'zh': '邮箱验证',
'ua': 'Підтвердження email',
},
'description': {
'ru': 'Письмо для подтверждения email адреса при регистрации',
'en': 'Email address verification letter sent during registration',
'zh': '注册时发送的邮箱验证邮件',
'ua': 'Лист для підтвердження email адреси при реєстрації',
},
'context_vars': ['username', 'verification_url', 'expire_hours'],
},
{
'type': 'password_reset',
'label': {'ru': 'Сброс пароля', 'en': 'Password Reset', 'zh': '重置密码', 'ua': 'Скидання пароля'},
'description': {
'ru': 'Письмо для сброса пароля',
'en': 'Password reset email',
'zh': '密码重置邮件',
'ua': 'Лист для скидання пароля',
},
'context_vars': ['username', 'reset_url', 'expire_hours'],
},
]
SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
'balance_topup': {
'formatted_amount': '500.00 ₽',
'formatted_balance': '1500.00 ₽',
'amount_rubles': 500,
'new_balance_rubles': 1500,
},
'balance_change': {
'formatted_amount': '-200.00 ₽',
'formatted_balance': '1300.00 ₽',
'amount_rubles': -200,
'new_balance_rubles': 1300,
},
'subscription_expiring': {'days_left': 3, 'expires_at': '2025-01-30'},
'subscription_expired': {},
'subscription_renewed': {'new_end_date': '2025-02-28', 'tariff_name': 'Premium'},
'subscription_activated': {'tariff_name': 'Premium', 'end_date': '2025-02-28'},
'autopay_success': {'formatted_amount': '300.00 ₽', 'formatted_balance': '200.00 ₽', 'new_end_date': '2025-02-28'},
'autopay_failed': {'reason': 'Card declined'},
'autopay_insufficient_funds': {'formatted_required': '300.00 ₽', 'formatted_balance': '50.00 ₽'},
'daily_debit': {'formatted_amount': '10.00 ₽', 'formatted_balance': '490.00 ₽'},
'daily_insufficient_funds': {'formatted_required': '10.00 ₽', 'formatted_balance': '5.00 ₽'},
'ban_notification': {'reason': 'Violation of terms of service'},
'unban_notification': {},
'warning_notification': {'message': 'Please review our terms of service'},
'referral_bonus': {'formatted_amount': '100.00 ₽', 'referral_name': 'John'},
'referral_registered': {'referral_name': 'John'},
'traffic_reset': {'traffic_limit': '100 GB'},
'payment_received': {'formatted_amount': '500.00 ₽', 'payment_method': 'YooKassa'},
'email_verification': {
'username': 'John',
'verification_url': 'https://example.com/verify?token=abc123',
'expire_hours': 24,
},
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
}
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
# ============ Schemas ============
class EmailTemplateUpdate(BaseModel):
"""Request to update an email template."""
subject: str = Field(..., min_length=1, max_length=500)
body_html: str = Field(..., min_length=1)
class EmailTemplatePreviewRequest(BaseModel):
"""Request to preview an email template."""
language: str = Field(default='ru')
subject: str = Field(default='')
body_html: str = Field(default='')
class EmailTemplateSendTestRequest(BaseModel):
"""Request to send a test email."""
language: str = Field(default='ru')
email: str = Field(default='')
# ============ Endpoints ============
@router.get('', summary='List all email template types')
async def list_template_types(
_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""List all available email template types with override status."""
overrides = await get_all_overrides(db)
# Build a map of overrides by type
override_map: dict[str, dict[str, bool]] = {}
for o in overrides:
ntype = o['notification_type']
if ntype not in override_map:
override_map[ntype] = {}
override_map[ntype][o['language']] = o['is_active']
result = []
for tpl_type in TEMPLATE_TYPES:
type_key = tpl_type['type']
languages = {}
for lang in AVAILABLE_LANGUAGES:
languages[lang] = {
'has_custom': lang in override_map.get(type_key, {}),
}
result.append(
{
**tpl_type,
'languages': languages,
}
)
return {'items': result, 'available_languages': AVAILABLE_LANGUAGES}
@router.get('/{notification_type}', summary='Get templates for a notification type')
async def get_templates_for_type(
notification_type: str,
_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Get all language templates for a specific notification type."""
# Validate type
valid_types = [t['type'] for t in TEMPLATE_TYPES]
if notification_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Unknown template type: {notification_type}',
)
# Get overrides from DB
overrides = await get_overrides_for_type(notification_type, db)
override_map = {o['language']: o for o in overrides}
# Get defaults from hardcoded templates
templates_instance = EmailNotificationTemplates()
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
# Get type metadata
type_meta = next(t for t in TEMPLATE_TYPES if t['type'] == notification_type)
# Build combined result per language
languages = {}
for lang in AVAILABLE_LANGUAGES:
# Get default template
try:
from app.services.notification_delivery_service import NotificationType
ntype_enum = NotificationType(notification_type)
default_template = templates_instance.get_template(ntype_enum, lang, sample_context)
except Exception:
default_template = None
default_subject = ''
default_body_html = ''
if default_template:
default_subject = default_template.get('subject', '')
default_body_html = default_template.get('body_html', '')
# Check for override
override = override_map.get(lang)
if override:
languages[lang] = {
'subject': override['subject'],
'body_html': override['body_html'],
'is_default': False,
'default_subject': default_subject,
'default_body_html': default_body_html,
}
else:
languages[lang] = {
'subject': default_subject,
'body_html': default_body_html,
'is_default': True,
'default_subject': default_subject,
'default_body_html': default_body_html,
}
return {
'notification_type': notification_type,
'label': type_meta['label'],
'description': type_meta['description'],
'context_vars': type_meta['context_vars'],
'languages': languages,
}
@router.put('/{notification_type}/{language}', summary='Save custom template')
async def update_template(
notification_type: str,
language: str,
data: EmailTemplateUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Save a custom email template override."""
valid_types = [t['type'] for t in TEMPLATE_TYPES]
if notification_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Unknown template type: {notification_type}',
)
if language not in AVAILABLE_LANGUAGES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid language: {language}. Available: {AVAILABLE_LANGUAGES}',
)
result = await save_template_override(
notification_type=notification_type,
language=language,
subject=data.subject,
body_html=data.body_html,
db=db,
)
logger.info(
'Админ %s обновил email шаблон %s/%s',
admin.id,
notification_type,
language,
)
return {'status': 'ok', 'template': result}
@router.delete('/{notification_type}/{language}', summary='Reset template to default')
async def reset_template(
notification_type: str,
language: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Delete custom template override, reverting to default."""
valid_types = [t['type'] for t in TEMPLATE_TYPES]
if notification_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Unknown template type: {notification_type}',
)
deleted = await delete_template_override(notification_type, language, db)
if deleted:
logger.info(
'Админ %s сбросил email шаблон %s/%s к дефолту',
admin.id,
notification_type,
language,
)
return {'status': 'ok', 'was_custom': deleted}
@router.post('/{notification_type}/preview', summary='Preview rendered template')
async def preview_template(
notification_type: str,
data: EmailTemplatePreviewRequest,
_admin: User = Depends(get_current_admin_user),
) -> dict[str, Any]:
"""Preview a rendered email template with sample data."""
valid_types = [t['type'] for t in TEMPLATE_TYPES]
if notification_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Unknown template type: {notification_type}',
)
templates_instance = EmailNotificationTemplates()
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
if data.body_html:
# Preview custom content wrapped in base template
rendered_html = templates_instance._get_base_template(data.body_html, language)
subject = data.subject or notification_type
else:
# Preview default template
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
try:
from app.services.notification_delivery_service import NotificationType
ntype_enum = NotificationType(notification_type)
default_template = templates_instance.get_template(ntype_enum, language, sample_context)
except Exception:
default_template = None
if default_template:
rendered_html = default_template['body_html']
subject = default_template['subject']
else:
rendered_html = '<p>Template not found</p>'
subject = 'N/A'
return {
'subject': subject,
'body_html': rendered_html,
}
@router.post('/{notification_type}/test', summary='Send test email')
async def send_test_email(
notification_type: str,
data: EmailTemplateSendTestRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> dict[str, Any]:
"""Send a test email to the admin's email address."""
from app.cabinet.services.email_service import email_service
if not email_service.is_configured():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='SMTP is not configured',
)
to_email = data.email or admin.email
if not to_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No email address provided and admin has no email',
)
valid_types = [t['type'] for t in TEMPLATE_TYPES]
if notification_type not in valid_types:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Unknown template type: {notification_type}',
)
language = data.language if data.language in AVAILABLE_LANGUAGES else 'ru'
sample_context = SAMPLE_CONTEXTS.get(notification_type, {})
templates_instance = EmailNotificationTemplates()
# Check for DB override
from ..services.email_template_overrides import get_template_override
override = await get_template_override(notification_type, language, db)
if override:
subject = override['subject']
body_html = templates_instance._get_base_template(override['body_html'], language)
else:
try:
from app.services.notification_delivery_service import NotificationType
ntype_enum = NotificationType(notification_type)
default_template = templates_instance.get_template(ntype_enum, language, sample_context)
except Exception:
default_template = None
if default_template:
subject = default_template['subject']
body_html = default_template['body_html']
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Template not found',
)
subject = f'[TEST] {subject}'
try:
success = await asyncio.to_thread(
email_service.send_email,
to_email=to_email,
subject=subject,
body_html=body_html,
)
except Exception as e:
logger.error('Ошибка отправки тестового email: %s', e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Failed to send test email: {e!s}',
)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to send test email',
)
logger.info(
'Админ %s отправил тестовый email %s/%s на %s',
admin.id,
notification_type,
language,
to_email,
)
return {'status': 'ok', 'sent_to': to_email}
-228
View File
@@ -1,228 +0,0 @@
"""Admin routes for payment method configuration in cabinet."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.payment_method_config_service import (
_get_method_defaults,
get_all_configs,
get_all_promo_groups,
get_config_by_method_id,
update_config,
update_sort_order,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/payment-methods', tags=['Cabinet Admin Payment Methods'])
# ============ Schemas ============
class SubOptionInfo(BaseModel):
id: str
name: str
class PaymentMethodConfigResponse(BaseModel):
method_id: str
sort_order: int
is_enabled: bool
display_name: str | None = None
default_display_name: str
sub_options: dict | None = None
available_sub_options: list[SubOptionInfo] | None = None
min_amount_kopeks: int | None = None
max_amount_kopeks: int | None = None
default_min_amount_kopeks: int
default_max_amount_kopeks: int
user_type_filter: str
first_topup_filter: str
promo_group_filter_mode: str
allowed_promo_group_ids: list[int] = Field(default_factory=list)
is_provider_configured: bool
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
class PaymentMethodConfigUpdateRequest(BaseModel):
is_enabled: bool | None = None
display_name: str | None = Field(default=None, description='Null to reset to default')
sub_options: dict | None = None
min_amount_kopeks: int | None = Field(default=None, ge=0)
max_amount_kopeks: int | None = Field(default=None, ge=0)
user_type_filter: str | None = Field(default=None, pattern='^(all|telegram|email)$')
first_topup_filter: str | None = Field(default=None, pattern='^(any|yes|no)$')
promo_group_filter_mode: str | None = Field(default=None, pattern='^(all|selected)$')
allowed_promo_group_ids: list[int] | None = None
# Allow explicitly resetting display_name to null
reset_display_name: bool = False
reset_min_amount: bool = False
reset_max_amount: bool = False
class SortOrderRequest(BaseModel):
method_ids: list[str]
class PromoGroupSimple(BaseModel):
id: int
name: str
class Config:
from_attributes = True
# ============ Helpers ============
def _enrich_config(config, defaults: dict) -> PaymentMethodConfigResponse:
"""Enrich a PaymentMethodConfig with env-var defaults."""
method_def = defaults.get(config.method_id, {})
available_sub_options = None
raw_options = method_def.get('available_sub_options')
if raw_options:
available_sub_options = [SubOptionInfo(**opt) for opt in raw_options]
return PaymentMethodConfigResponse(
method_id=config.method_id,
sort_order=config.sort_order,
is_enabled=config.is_enabled,
display_name=config.display_name,
default_display_name=method_def.get('default_display_name', config.method_id),
sub_options=config.sub_options,
available_sub_options=available_sub_options,
min_amount_kopeks=config.min_amount_kopeks,
max_amount_kopeks=config.max_amount_kopeks,
default_min_amount_kopeks=method_def.get('default_min', 1000),
default_max_amount_kopeks=method_def.get('default_max', 10000000),
user_type_filter=config.user_type_filter,
first_topup_filter=config.first_topup_filter,
promo_group_filter_mode=config.promo_group_filter_mode,
allowed_promo_group_ids=[pg.id for pg in config.allowed_promo_groups],
is_provider_configured=method_def.get('is_configured', False),
created_at=config.created_at,
updated_at=config.updated_at,
)
# ============ Routes ============
@router.get('', response_model=list[PaymentMethodConfigResponse])
async def list_payment_methods(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all payment method configurations."""
configs = await get_all_configs(db)
defaults = _get_method_defaults()
return [_enrich_config(c, defaults) for c in configs]
@router.get('/promo-groups', response_model=list[PromoGroupSimple])
async def list_promo_groups(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all promo groups for filter selector."""
groups = await get_all_promo_groups(db)
return [PromoGroupSimple(id=g.id, name=g.name) for g in groups]
@router.get('/{method_id}', response_model=PaymentMethodConfigResponse)
async def get_payment_method(
method_id: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get a single payment method configuration."""
config = await get_config_by_method_id(db, method_id)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Payment method not found: {method_id}',
)
defaults = _get_method_defaults()
return _enrich_config(config, defaults)
@router.put('/order')
async def update_payment_methods_order(
request: SortOrderRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Batch update sort order for payment methods."""
await update_sort_order(db, request.method_ids)
logger.info(f'Admin {admin.id} updated payment methods order: {request.method_ids}')
return {'success': True}
@router.put('/{method_id}', response_model=PaymentMethodConfigResponse)
async def update_payment_method(
method_id: str,
request: PaymentMethodConfigUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a payment method configuration."""
# Build update data dict
data = {}
if request.is_enabled is not None:
data['is_enabled'] = request.is_enabled
if request.reset_display_name:
data['display_name'] = None
elif request.display_name is not None:
data['display_name'] = request.display_name.strip() or None
if request.sub_options is not None:
data['sub_options'] = request.sub_options
if request.reset_min_amount:
data['min_amount_kopeks'] = None
elif request.min_amount_kopeks is not None:
data['min_amount_kopeks'] = request.min_amount_kopeks
if request.reset_max_amount:
data['max_amount_kopeks'] = None
elif request.max_amount_kopeks is not None:
data['max_amount_kopeks'] = request.max_amount_kopeks
if request.user_type_filter is not None:
data['user_type_filter'] = request.user_type_filter
if request.first_topup_filter is not None:
data['first_topup_filter'] = request.first_topup_filter
if request.promo_group_filter_mode is not None:
data['promo_group_filter_mode'] = request.promo_group_filter_mode
promo_group_ids = request.allowed_promo_group_ids
config = await update_config(db, method_id, data, promo_group_ids)
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Payment method not found: {method_id}',
)
logger.info(f'Admin {admin.id} updated payment method config: {method_id}')
defaults = _get_method_defaults()
return _enrich_config(config, defaults)
-420
View File
@@ -1,420 +0,0 @@
"""Admin routes for payment verification in cabinet."""
import logging
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PaymentMethod, User
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
SUPPORTED_MANUAL_CHECK_METHODS,
PendingPayment,
get_payment_record,
list_recent_pending_payments,
method_display_name,
run_manual_check,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments'])
# ============ Schemas ============
class PendingPaymentResponse(BaseModel):
"""Pending payment details."""
id: int
method: str
method_display: str
identifier: str
amount_kopeks: int
amount_rubles: float
status: str
status_emoji: str
status_text: str
is_paid: bool
is_checkable: bool
created_at: datetime
expires_at: datetime | None = None
payment_url: str | None = None
user_id: int | None = None
user_telegram_id: int | None = None
user_username: str | None = None
class Config:
from_attributes = True
class PendingPaymentListResponse(BaseModel):
"""Paginated list of pending payments."""
items: list[PendingPaymentResponse]
total: int
page: int
per_page: int
pages: int
class ManualCheckResponse(BaseModel):
"""Response after manual payment status check."""
success: bool
message: str
payment: PendingPaymentResponse | None = None
status_changed: bool = False
old_status: str | None = None
new_status: str | None = None
class PaymentsStatsResponse(BaseModel):
"""Statistics about pending payments."""
total_pending: int
by_method: dict
# ============ Helper functions ============
def _get_status_info(record: PendingPayment) -> tuple[str, str]:
"""Get status emoji and text for a pending payment."""
status_str = (record.status or '').lower()
if record.is_paid:
return '', 'Оплачено'
if record.method == PaymentMethod.PAL24:
mapping = {
'new': ('', 'Ожидает оплаты'),
'process': ('', 'Обрабатывается'),
'success': ('', 'Оплачено'),
'fail': ('', 'Ошибка'),
'canceled': ('', 'Отменено'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.MULENPAY:
mapping = {
'created': ('', 'Ожидает оплаты'),
'processing': ('', 'Обрабатывается'),
'hold': ('🔒', 'На удержании'),
'success': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
'error': ('', 'Ошибка'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.WATA:
mapping = {
'opened': ('', 'Ожидает оплаты'),
'pending': ('', 'Ожидает оплаты'),
'processing': ('', 'Обрабатывается'),
'paid': ('', 'Оплачено'),
'closed': ('', 'Оплачено'),
'declined': ('', 'Отклонено'),
'canceled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.PLATEGA:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'inprogress': ('', 'Обрабатывается'),
'confirmed': ('', 'Оплачено'),
'failed': ('', 'Ошибка'),
'canceled': ('', 'Отменено'),
'expired': ('', 'Истёк'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.HELEKET:
if status_str in {'pending', 'created', 'waiting', 'check', 'processing'}:
return '', 'Ожидает оплаты'
if status_str in {'paid', 'paid_over'}:
return '', 'Оплачено'
if status_str in {'cancel', 'canceled', 'fail', 'failed', 'expired'}:
return '', 'Отменено'
return '', 'Неизвестно'
if record.method == PaymentMethod.YOOKASSA:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'waiting_for_capture': ('', 'Обрабатывается'),
'succeeded': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.CRYPTOBOT:
mapping = {
'active': ('', 'Ожидает оплаты'),
'paid': ('', 'Оплачено'),
'expired': ('', 'Истёк'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.CLOUDPAYMENTS:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'authorized': ('', 'Авторизовано'),
'completed': ('', 'Оплачено'),
'failed': ('', 'Ошибка'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
if record.method == PaymentMethod.FREEKASSA:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'success': ('', 'Оплачено'),
'paid': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
'error': ('', 'Ошибка'),
}
return mapping.get(status_str, ('', 'Неизвестно'))
return '', 'Неизвестно'
def _is_checkable(record: PendingPayment) -> bool:
"""Check if payment can be manually checked."""
if record.method not in SUPPORTED_MANUAL_CHECK_METHODS:
return False
if not record.is_recent():
return False
status_str = (record.status or '').lower()
if record.method == PaymentMethod.PAL24:
return status_str in {'new', 'process'}
if record.method == PaymentMethod.MULENPAY:
return status_str in {'created', 'processing', 'hold'}
if record.method == PaymentMethod.WATA:
return status_str in {'opened', 'pending', 'processing', 'inprogress', 'in_progress'}
if record.method == PaymentMethod.PLATEGA:
return status_str in {'pending', 'inprogress', 'in_progress'}
if record.method == PaymentMethod.HELEKET:
return status_str not in {'paid', 'paid_over', 'cancel', 'canceled', 'fail', 'failed', 'expired'}
if record.method == PaymentMethod.YOOKASSA:
return status_str in {'pending', 'waiting_for_capture'}
if record.method == PaymentMethod.CRYPTOBOT:
return status_str in {'active'}
if record.method == PaymentMethod.CLOUDPAYMENTS:
return status_str in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
return status_str in {'pending', 'created', 'processing'}
return False
def _get_payment_url(record: PendingPayment) -> str | None:
"""Extract payment URL from record."""
payment = record.payment
payment_url = getattr(payment, 'payment_url', None)
if record.method == PaymentMethod.PAL24:
payment_url = getattr(payment, 'link_url', None) or getattr(payment, 'link_page_url', None) or payment_url
elif record.method == PaymentMethod.WATA:
payment_url = getattr(payment, 'url', None) or payment_url
elif record.method == PaymentMethod.YOOKASSA:
payment_url = getattr(payment, 'confirmation_url', None) or payment_url
elif record.method == PaymentMethod.CRYPTOBOT:
payment_url = (
getattr(payment, 'bot_invoice_url', None)
or getattr(payment, 'mini_app_invoice_url', None)
or getattr(payment, 'web_app_invoice_url', None)
or payment_url
)
elif record.method == PaymentMethod.PLATEGA:
payment_url = getattr(payment, 'redirect_url', None) or payment_url
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
payment_url = getattr(payment, 'payment_url', None) or payment_url
return payment_url
def _record_to_response(record: PendingPayment) -> PendingPaymentResponse:
"""Convert PendingPayment to API response."""
status_emoji, status_text = _get_status_info(record)
return PendingPaymentResponse(
id=record.local_id,
method=record.method.value,
method_display=method_display_name(record.method),
identifier=record.identifier,
amount_kopeks=record.amount_kopeks,
amount_rubles=record.amount_kopeks / 100,
status=record.status or '',
status_emoji=status_emoji,
status_text=status_text,
is_paid=record.is_paid,
is_checkable=_is_checkable(record),
created_at=record.created_at,
expires_at=record.expires_at,
payment_url=_get_payment_url(record),
user_id=record.user.id if record.user else None,
user_telegram_id=record.user.telegram_id if record.user else None,
user_username=record.user.username if record.user else None,
)
# ============ Routes ============
@router.get('', response_model=PendingPaymentListResponse)
async def get_all_pending_payments(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
method_filter: str | None = Query(None, description='Filter by payment method'),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all pending payments for admin verification."""
all_pending = await list_recent_pending_payments(db)
# Apply method filter if specified
if method_filter:
try:
filter_method = PaymentMethod(method_filter)
all_pending = [p for p in all_pending if p.method == filter_method]
except ValueError:
pass
total = len(all_pending)
pages = math.ceil(total / per_page) if total > 0 else 1
# Paginate
start_idx = (page - 1) * per_page
page_payments = all_pending[start_idx : start_idx + per_page]
items = [_record_to_response(p) for p in page_payments]
return PendingPaymentListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get('/stats', response_model=PaymentsStatsResponse)
async def get_payments_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get statistics about pending payments."""
all_pending = await list_recent_pending_payments(db)
by_method = {}
for p in all_pending:
method_name = method_display_name(p.method)
if method_name not in by_method:
by_method[method_name] = 0
by_method[method_name] += 1
return PaymentsStatsResponse(
total_pending=len(all_pending),
by_method=by_method,
)
@router.get('/{method}/{payment_id}', response_model=PendingPaymentResponse)
async def get_pending_payment_details(
method: str,
payment_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get details of a specific pending payment."""
try:
payment_method = PaymentMethod(method)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
)
record = await get_payment_record(db, payment_method, payment_id)
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Payment not found',
)
return _record_to_response(record)
@router.post('/{method}/{payment_id}/check', response_model=ManualCheckResponse)
async def check_payment_status(
method: str,
payment_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Manually check and update payment status."""
try:
payment_method = PaymentMethod(method)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid payment method: {method}',
)
# Get current record
record = await get_payment_record(db, payment_method, payment_id)
if not record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Payment not found',
)
# Check if manual check is available
if not _is_checkable(record):
return ManualCheckResponse(
success=False,
message='Ручная проверка недоступна для этого платежа',
payment=_record_to_response(record),
status_changed=False,
)
old_status = record.status
old_is_paid = record.is_paid
# Run manual check
payment_service = PaymentService()
updated = await run_manual_check(db, payment_method, payment_id, payment_service)
if not updated:
return ManualCheckResponse(
success=False,
message='Не удалось проверить статус платежа',
payment=_record_to_response(record),
status_changed=False,
)
status_changed = updated.status != old_status or updated.is_paid != old_is_paid
if status_changed:
_, new_status_text = _get_status_info(updated)
message = f'Статус обновлён: {new_status_text}'
logger.info(f'Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}')
else:
message = 'Статус не изменился'
return ManualCheckResponse(
success=True,
message=message,
payment=_record_to_response(updated),
status_changed=status_changed,
old_status=old_status,
new_status=updated.status,
)
-637
View File
@@ -1,637 +0,0 @@
"""Admin promo offers routes for cabinet."""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime
from typing import Any
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.discount_offer import (
count_discount_offers,
list_discount_offers,
upsert_discount_offer,
)
from app.database.crud.promo_offer_log import list_promo_offer_logs
from app.database.crud.promo_offer_template import (
ensure_default_templates,
get_promo_offer_template_by_id,
list_promo_offer_templates,
update_promo_offer_template,
)
from app.database.crud.user import get_user_by_email, get_user_by_telegram_id
from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, User
from app.handlers.admin.messages import get_custom_users, get_target_users
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers'])
# ============== Schemas ==============
class PromoOfferUserInfo(BaseModel):
id: int
telegram_id: int | None = None # Can be None for email-only users
email: str | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
full_name: str | None = None
class PromoOfferResponse(BaseModel):
id: int
user_id: int
subscription_id: int | None = None
notification_type: str | None = None
discount_percent: int | None = None
bonus_amount_kopeks: int | None = None
expires_at: datetime | None = None
claimed_at: datetime | None = None
is_active: bool
effect_type: str | None = None
extra_data: dict[str, Any] = Field(default_factory=dict)
created_at: datetime | None = None
updated_at: datetime | None = None
user: PromoOfferUserInfo | None = None
class PromoOfferListResponse(BaseModel):
items: list[PromoOfferResponse]
total: int
limit: int
offset: int
class PromoOfferTemplateResponse(BaseModel):
id: int
name: str
offer_type: str
message_text: str
button_text: str
valid_hours: int
discount_percent: int
bonus_amount_kopeks: int
active_discount_hours: int | None = None
test_duration_hours: int | None = None
test_squad_uuids: list[str] = Field(default_factory=list)
is_active: bool
created_by: int | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class PromoOfferTemplateListResponse(BaseModel):
items: list[PromoOfferTemplateResponse]
class PromoOfferTemplateUpdateRequest(BaseModel):
name: str | None = None
message_text: str | None = None
button_text: str | None = None
valid_hours: int | None = Field(None, ge=1)
discount_percent: int | None = Field(None, ge=0)
bonus_amount_kopeks: int | None = Field(None, ge=0)
active_discount_hours: int | None = Field(None, ge=1)
test_duration_hours: int | None = Field(None, ge=1)
test_squad_uuids: list[str] | None = None
is_active: bool | None = None
class PromoOfferBroadcastRequest(BaseModel):
notification_type: str = Field(..., min_length=1)
valid_hours: int = Field(..., ge=1)
discount_percent: int = Field(0, ge=0)
bonus_amount_kopeks: int = Field(0, ge=0)
effect_type: str = Field('percent_discount', min_length=1)
extra_data: dict[str, Any] = Field(default_factory=dict)
target: str | None = None
user_id: int | None = None
telegram_id: int | None = None
email: str | None = Field(None, description='User email (for email-only users)')
# Telegram notification options
send_notification: bool = Field(False, description='Send Telegram notification to users')
message_text: str | None = Field(None, description='Custom message text (HTML)')
button_text: str | None = Field(None, description='Button text')
class PromoOfferBroadcastResponse(BaseModel):
created_offers: int
user_ids: list[int]
target: str | None = None
notifications_sent: int = 0
notifications_failed: int = 0
class PromoOfferLogOfferInfo(BaseModel):
id: int
notification_type: str | None = None
discount_percent: int | None = None
bonus_amount_kopeks: int | None = None
effect_type: str | None = None
expires_at: datetime | None = None
claimed_at: datetime | None = None
is_active: bool | None = None
class PromoOfferLogResponse(BaseModel):
id: int
user_id: int | None = None
offer_id: int | None = None
action: str
source: str | None = None
percent: int | None = None
effect_type: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
user: PromoOfferUserInfo | None = None
offer: PromoOfferLogOfferInfo | None = None
class PromoOfferLogListResponse(BaseModel):
items: list[PromoOfferLogResponse]
total: int
limit: int
offset: int
# ============== Helpers ==============
def _serialize_user(user: User | None) -> PromoOfferUserInfo | None:
if not user:
return None
return PromoOfferUserInfo(
id=user.id,
telegram_id=user.telegram_id,
email=user.email,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
full_name=getattr(user, 'full_name', None),
)
def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse:
return PromoOfferResponse(
id=offer.id,
user_id=offer.user_id,
subscription_id=offer.subscription_id,
notification_type=offer.notification_type,
discount_percent=offer.discount_percent,
bonus_amount_kopeks=offer.bonus_amount_kopeks,
expires_at=offer.expires_at,
claimed_at=offer.claimed_at,
is_active=offer.is_active,
effect_type=offer.effect_type,
extra_data=offer.extra_data or {},
created_at=offer.created_at,
updated_at=offer.updated_at,
user=_serialize_user(getattr(offer, 'user', None)),
)
def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateResponse:
return PromoOfferTemplateResponse(
id=template.id,
name=template.name,
offer_type=template.offer_type,
message_text=template.message_text,
button_text=template.button_text,
valid_hours=template.valid_hours,
discount_percent=template.discount_percent,
bonus_amount_kopeks=template.bonus_amount_kopeks,
active_discount_hours=template.active_discount_hours,
test_duration_hours=template.test_duration_hours,
test_squad_uuids=[str(uuid) for uuid in (template.test_squad_uuids or [])],
is_active=template.is_active,
created_by=template.created_by,
created_at=template.created_at,
updated_at=template.updated_at,
)
def _serialize_log(entry: PromoOfferLog) -> PromoOfferLogResponse:
user_info = _serialize_user(getattr(entry, 'user', None))
offer = getattr(entry, 'offer', None)
offer_info: PromoOfferLogOfferInfo | None = None
if offer:
offer_info = PromoOfferLogOfferInfo(
id=offer.id,
notification_type=offer.notification_type,
discount_percent=offer.discount_percent,
bonus_amount_kopeks=offer.bonus_amount_kopeks,
effect_type=offer.effect_type,
expires_at=offer.expires_at,
claimed_at=offer.claimed_at,
is_active=offer.is_active,
)
return PromoOfferLogResponse(
id=entry.id,
user_id=entry.user_id,
offer_id=entry.offer_id,
action=entry.action,
source=entry.source,
percent=entry.percent,
effect_type=entry.effect_type,
details=entry.details or {},
created_at=entry.created_at,
user=user_info,
offer=offer_info,
)
async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
normalized = target.strip().lower()
if normalized.startswith('custom_'):
criteria = normalized[len('custom_') :]
return await get_custom_users(db, criteria)
return await get_target_users(db, normalized)
# ============== Template Endpoints ==============
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
async def list_templates(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateListResponse:
"""Get list of promo offer templates."""
templates = await list_promo_offer_templates(db)
# Initialize default templates if none exist
if not templates:
templates = await ensure_default_templates(db, created_by=admin.id)
return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates])
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
async def get_template(
template_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateResponse:
"""Get a promo offer template."""
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found')
return _serialize_template(template)
@router.patch('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
async def update_template(
template_id: int,
payload: PromoOfferTemplateUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferTemplateResponse:
"""Update a promo offer template."""
template = await get_promo_offer_template_by_id(db, template_id)
if not template:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found')
if payload.test_squad_uuids is not None:
normalized_squads = [str(uuid).strip() for uuid in payload.test_squad_uuids if str(uuid).strip()]
else:
normalized_squads = None
updated_template = await update_promo_offer_template(
db,
template,
name=payload.name,
message_text=payload.message_text,
button_text=payload.button_text,
valid_hours=payload.valid_hours,
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
active_discount_hours=payload.active_discount_hours,
test_duration_hours=payload.test_duration_hours,
test_squad_uuids=normalized_squads,
is_active=payload.is_active,
)
return _serialize_template(updated_template)
# ============== Offer Endpoints ==============
@router.get('', response_model=PromoOfferListResponse)
async def list_offers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user_id: int | None = Query(None, ge=1),
is_active: bool | None = Query(None),
) -> PromoOfferListResponse:
"""Get list of promo offers."""
offers = await list_discount_offers(
db,
offset=offset,
limit=limit,
user_id=user_id,
is_active=is_active,
)
total = await count_discount_offers(
db,
user_id=user_id,
is_active=is_active,
)
return PromoOfferListResponse(
items=[_serialize_offer(offer) for offer in offers],
total=total,
limit=limit,
offset=offset,
)
def _get_bot() -> Bot:
"""Create bot instance for sending notifications."""
return Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
def _build_default_promo_message(
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
) -> str:
"""Build default promo notification message."""
lines = ['🎁 <b>Специальное предложение для вас!</b>\n']
if discount_percent > 0:
lines.append(f'🔥 Скидка <b>{discount_percent}%</b> на подписку')
if bonus_amount_kopeks > 0:
bonus_rub = bonus_amount_kopeks / 100
lines.append(f'💰 Бонус <b>{bonus_rub:.0f}₽</b> на баланс')
lines.append(f'\n⏰ Предложение действует <b>{valid_hours} ч.</b>')
lines.append('\nНажмите кнопку ниже, чтобы активировать!')
return '\n'.join(lines)
async def _send_promo_notifications(
offers_to_notify: list[tuple[User, DiscountOffer]],
message_text: str | None,
button_text: str | None,
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
) -> tuple[int, int]:
"""Send Telegram notifications for promo offers.
Returns:
Tuple of (sent_count, failed_count)
"""
if not offers_to_notify:
return 0, 0
bot = _get_bot()
sent = 0
failed = 0
# Build message text
text = message_text or _build_default_promo_message(
discount_percent=discount_percent,
bonus_amount_kopeks=bonus_amount_kopeks,
valid_hours=valid_hours,
)
# Default button text
btn_text = button_text or '🎁 Получить'
semaphore = asyncio.Semaphore(20)
async def send_single(user: User, offer: DiscountOffer) -> bool:
# Skip email-only users (no telegram_id)
if not user.telegram_id:
logger.debug(f'Skipping promo notification for email-only user {user.id}')
return False
async with semaphore:
try:
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
build_miniapp_or_callback_button(
text=btn_text,
callback_data=f'claim_discount_{offer.id}',
)
],
[
InlineKeyboardButton(
text='❌ Закрыть',
callback_data='promo_offer_close',
)
],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=text,
reply_markup=keyboard,
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
logger.warning(
'Failed to send promo notification to user %s: %s',
user.telegram_id,
exc,
)
return False
except Exception as exc:
logger.error(
'Error sending promo notification to user %s: %s',
user.telegram_id,
exc,
)
return False
# Send in batches
batch_size = 50
for i in range(0, len(offers_to_notify), batch_size):
batch = offers_to_notify[i : i + batch_size]
tasks = [send_single(user, offer) for user, offer in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, bool) and result:
sent += 1
else:
failed += 1
# Small delay between batches
if i + batch_size < len(offers_to_notify):
await asyncio.sleep(0.1)
# Close bot session
await bot.session.close()
return sent, failed
@router.post('/broadcast', response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def broadcast_offer(
payload: PromoOfferBroadcastRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoOfferBroadcastResponse:
"""Broadcast promo offer to users with optional Telegram notification."""
recipients: dict[int, User] = {}
# Resolve target segment
if payload.target:
users = await _resolve_target_users(db, payload.target)
recipients.update({user.id: user for user in users if user and user.id})
# Resolve specific user
target_user_id = payload.user_id
user: User | None = None
if payload.telegram_id is not None:
user = await get_user_by_telegram_id(db, payload.telegram_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found by telegram_id')
if target_user_id and target_user_id != user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
'Provided user_id does not match telegram_id',
)
target_user_id = user.id
# Support email lookup for email-only users
if payload.email is not None and user is None:
user = await get_user_by_email(db, payload.email)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found by email')
if target_user_id and target_user_id != user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
'Provided user_id does not match email',
)
target_user_id = user.id
if target_user_id is not None:
if user is None:
user = await db.get(User, target_user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
recipients[target_user_id] = user
if not recipients:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
'No recipients: specify target or user',
)
# Create offers for all recipients and collect (user, offer) pairs
created_offers = 0
offers_to_notify: list[tuple[User, DiscountOffer]] = []
for recipient in recipients.values():
offer = await upsert_discount_offer(
db,
user_id=recipient.id,
subscription_id=None,
notification_type=payload.notification_type.strip(),
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
valid_hours=payload.valid_hours,
effect_type=payload.effect_type,
extra_data=payload.extra_data,
)
if offer:
created_offers += 1
offers_to_notify.append((recipient, offer))
# Send Telegram notifications if requested
notifications_sent = 0
notifications_failed = 0
if payload.send_notification and offers_to_notify:
# Render placeholders in custom message text
rendered_message_text = payload.message_text
if rendered_message_text:
extra = payload.extra_data or {}
try:
rendered_message_text = rendered_message_text.format(
discount_percent=payload.discount_percent,
valid_hours=payload.valid_hours,
active_discount_hours=extra.get('active_discount_hours') or payload.valid_hours,
test_duration_hours=extra.get('test_duration_hours') or 0,
server_name=extra.get('server_name', ''),
)
except (KeyError, ValueError, IndexError):
logger.warning('Failed to render promo message placeholders')
notifications_sent, notifications_failed = await _send_promo_notifications(
offers_to_notify=offers_to_notify,
message_text=rendered_message_text,
button_text=payload.button_text,
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
valid_hours=payload.valid_hours,
)
return PromoOfferBroadcastResponse(
created_offers=created_offers,
user_ids=list(recipients.keys()),
target=payload.target,
notifications_sent=notifications_sent,
notifications_failed=notifications_failed,
)
# ============== Log Endpoints ==============
@router.get('/logs', response_model=PromoOfferLogListResponse)
async def get_logs(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user_id: int | None = Query(None, ge=1),
action: str | None = Query(None, min_length=1),
) -> PromoOfferLogListResponse:
"""Get promo offer logs."""
logs, total = await list_promo_offer_logs(
db,
offset=offset,
limit=limit,
user_id=user_id,
action=action,
)
return PromoOfferLogListResponse(
items=[_serialize_log(entry) for entry in logs],
total=int(total),
limit=limit,
offset=offset,
)
-660
View File
@@ -1,660 +0,0 @@
"""Admin promocodes routes for cabinet."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.promo_group import (
count_promo_group_members,
count_promo_groups,
create_promo_group,
delete_promo_group,
get_promo_group_by_id,
get_promo_groups_with_counts,
update_promo_group,
)
from app.database.crud.promocode import (
create_promocode,
delete_promocode,
get_promocode_by_code,
get_promocode_by_id,
get_promocode_statistics,
get_promocodes_count,
get_promocodes_list,
update_promocode,
)
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User
from ..dependencies import get_cabinet_db, get_current_admin_user
router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes'])
# ============== Schemas ==============
class PromoCodeResponse(BaseModel):
id: int
code: str
type: PromoCodeType
balance_bonus_kopeks: int
balance_bonus_rubles: float
subscription_days: int
max_uses: int
current_uses: int
uses_left: int
is_active: bool
is_valid: bool
first_purchase_only: bool
valid_from: datetime
valid_until: datetime | None = None
promo_group_id: int | None = None
created_by: int | None = None
created_at: datetime
updated_at: datetime
class PromoCodeListResponse(BaseModel):
items: list[PromoCodeResponse]
total: int
limit: int
offset: int
class PromoCodeRecentUse(BaseModel):
id: int
user_id: int
user_username: str | None = None
user_full_name: str | None = None
user_telegram_id: int | None = None
used_at: datetime
class PromoCodeDetailResponse(PromoCodeResponse):
total_uses: int
today_uses: int
recent_uses: list[PromoCodeRecentUse] = Field(default_factory=list)
class PromoCodeCreateRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=50)
type: PromoCodeType
balance_bonus_kopeks: int = 0
subscription_days: int = 0
max_uses: int = Field(default=1, ge=0)
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool = True
first_purchase_only: bool = False
promo_group_id: int | None = None
class PromoCodeUpdateRequest(BaseModel):
code: str | None = Field(default=None, min_length=1, max_length=50)
type: PromoCodeType | None = None
balance_bonus_kopeks: int | None = None
subscription_days: int | None = None
max_uses: int | None = Field(default=None, ge=0)
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool | None = None
first_purchase_only: bool | None = None
promo_group_id: int | None = None
# ============== PromoGroup Schemas ==============
class PromoGroupResponse(BaseModel):
id: int
name: str
server_discount_percent: int
traffic_discount_percent: int
device_discount_percent: int
period_discounts: dict[int, int] = Field(default_factory=dict)
auto_assign_total_spent_kopeks: int | None = None
apply_discounts_to_addons: bool
is_default: bool
members_count: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
class PromoGroupListResponse(BaseModel):
items: list[PromoGroupResponse]
total: int
limit: int
offset: int
class PromoGroupCreateRequest(BaseModel):
name: str
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: dict[int, int] | None = None
auto_assign_total_spent_kopeks: int | None = None
apply_discounts_to_addons: bool = True
is_default: bool = False
class PromoGroupUpdateRequest(BaseModel):
name: str | None = None
server_discount_percent: int | None = None
traffic_discount_percent: int | None = None
device_discount_percent: int | None = None
period_discounts: dict[int, int] | None = None
auto_assign_total_spent_kopeks: int | None = None
apply_discounts_to_addons: bool | None = None
is_default: bool | None = None
# ============== Helpers ==============
def _normalize_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is not None and value.utcoffset() is not None:
return value.astimezone(UTC).replace(tzinfo=None)
if value.tzinfo is not None:
return value.replace(tzinfo=None)
return value
def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
promo_type = PromoCodeType(promocode.type)
return PromoCodeResponse(
id=promocode.id,
code=promocode.code,
type=promo_type,
balance_bonus_kopeks=promocode.balance_bonus_kopeks,
balance_bonus_rubles=round(promocode.balance_bonus_kopeks / 100, 2),
subscription_days=promocode.subscription_days,
max_uses=promocode.max_uses,
current_uses=promocode.current_uses,
uses_left=promocode.uses_left,
is_active=promocode.is_active,
is_valid=promocode.is_valid,
first_purchase_only=promocode.first_purchase_only,
valid_from=promocode.valid_from,
valid_until=promocode.valid_until,
promo_group_id=promocode.promo_group_id,
created_by=promocode.created_by,
created_at=promocode.created_at,
updated_at=promocode.updated_at,
)
def _serialize_recent_use(use: PromoCodeUse) -> PromoCodeRecentUse:
return PromoCodeRecentUse(
id=use.id,
user_id=use.user_id,
user_username=getattr(use, 'user_username', None),
user_full_name=getattr(use, 'user_full_name', None),
user_telegram_id=getattr(use, 'user_telegram_id', None),
used_at=use.used_at,
)
def _normalize_period_discounts(group: PromoGroup) -> dict[int, int]:
raw = group.period_discounts or {}
normalized: dict[int, int] = {}
if isinstance(raw, dict):
for key, value in raw.items():
try:
normalized[int(key)] = int(value)
except (TypeError, ValueError):
continue
return normalized
def _serialize_promo_group(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
return PromoGroupResponse(
id=group.id,
name=group.name,
server_discount_percent=group.server_discount_percent,
traffic_discount_percent=group.traffic_discount_percent,
device_discount_percent=group.device_discount_percent,
period_discounts=_normalize_period_discounts(group),
auto_assign_total_spent_kopeks=group.auto_assign_total_spent_kopeks,
apply_discounts_to_addons=group.apply_discounts_to_addons,
is_default=group.is_default,
members_count=members_count,
created_at=getattr(group, 'created_at', None),
updated_at=getattr(group, 'updated_at', None),
)
def _validate_create_payload(payload: PromoCodeCreateRequest) -> None:
code = payload.code.strip()
if not code:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Code must not be empty')
normalized_valid_from = _normalize_datetime(payload.valid_from)
normalized_valid_until = _normalize_datetime(payload.valid_until)
if payload.type == PromoCodeType.BALANCE and payload.balance_bonus_kopeks <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Balance bonus must be positive for balance promo codes')
if payload.type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION}:
if payload.subscription_days <= 0:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, 'Subscription days must be positive for this promo code type'
)
if payload.type == PromoCodeType.DISCOUNT:
if payload.balance_bonus_kopeks <= 0 or payload.balance_bonus_kopeks > 100:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount percent must be between 1 and 100')
if payload.subscription_days <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount validity hours must be positive')
if normalized_valid_from and normalized_valid_until and normalized_valid_from > normalized_valid_until:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'valid_from cannot be greater than valid_until')
def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCode) -> None:
if payload.code is not None and not payload.code.strip():
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Code must not be empty')
if payload.type is not None:
new_type = payload.type
else:
new_type = PromoCodeType(promocode.type)
balance_bonus = (
payload.balance_bonus_kopeks if payload.balance_bonus_kopeks is not None else promocode.balance_bonus_kopeks
)
subscription_days = (
payload.subscription_days if payload.subscription_days is not None else promocode.subscription_days
)
if new_type == PromoCodeType.BALANCE and balance_bonus <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Balance bonus must be positive for balance promo codes')
if new_type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION}:
if subscription_days <= 0:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, 'Subscription days must be positive for this promo code type'
)
if new_type == PromoCodeType.DISCOUNT:
if balance_bonus <= 0 or balance_bonus > 100:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount percent must be between 1 and 100')
if subscription_days <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Discount validity hours must be positive')
valid_from = _normalize_datetime(payload.valid_from) if payload.valid_from is not None else promocode.valid_from
valid_until = _normalize_datetime(payload.valid_until) if payload.valid_until is not None else promocode.valid_until
if valid_from and valid_until and valid_from > valid_until:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'valid_from cannot be greater than valid_until')
if payload.max_uses is not None and payload.max_uses != 0 and payload.max_uses < promocode.current_uses:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'max_uses cannot be less than current uses')
# ============== Promocode Endpoints ==============
@router.get('', response_model=PromoCodeListResponse)
async def list_promocodes(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
is_active: bool | None = Query(default=None),
) -> PromoCodeListResponse:
"""Get list of all promocodes."""
total = await get_promocodes_count(db, is_active=is_active) or 0
promocodes = await get_promocodes_list(db, offset=offset, limit=limit, is_active=is_active)
return PromoCodeListResponse(
items=[_serialize_promocode(promocode) for promocode in promocodes],
total=int(total),
limit=limit,
offset=offset,
)
@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse)
async def get_promocode(
promocode_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeDetailResponse:
"""Get promocode details with usage statistics."""
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
stats = await get_promocode_statistics(db, promocode_id)
base = _serialize_promocode(promocode)
recent_uses = [_serialize_recent_use(use) for use in stats.get('recent_uses', [])]
return PromoCodeDetailResponse(
**base.model_dump(),
total_uses=stats.get('total_uses', 0),
today_uses=stats.get('today_uses', 0),
recent_uses=recent_uses,
)
@router.post('', response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED)
async def create_promocode_endpoint(
payload: PromoCodeCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeResponse:
"""Create a new promocode."""
_validate_create_payload(payload)
normalized_code = payload.code.strip().upper()
normalized_valid_from = _normalize_datetime(payload.valid_from)
normalized_valid_until = _normalize_datetime(payload.valid_until)
existing = await get_promocode_by_code(db, normalized_code)
if existing:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
# 0 means unlimited — convert to large number for is_valid check (current_uses < max_uses)
effective_max_uses = 999999 if payload.max_uses == 0 else payload.max_uses
promocode = await create_promocode(
db,
code=normalized_code,
type=payload.type,
balance_bonus_kopeks=payload.balance_bonus_kopeks,
subscription_days=payload.subscription_days,
max_uses=effective_max_uses,
valid_until=normalized_valid_until,
created_by=admin.id,
)
update_fields = {}
if normalized_valid_from is not None:
update_fields['valid_from'] = normalized_valid_from
if payload.is_active is not None and payload.is_active != promocode.is_active:
update_fields['is_active'] = payload.is_active
if normalized_valid_until is not None:
update_fields['valid_until'] = normalized_valid_until
if payload.first_purchase_only:
update_fields['first_purchase_only'] = payload.first_purchase_only
if payload.promo_group_id is not None:
update_fields['promo_group_id'] = payload.promo_group_id
if update_fields:
promocode = await update_promocode(db, promocode, **update_fields)
return _serialize_promocode(promocode)
@router.patch('/{promocode_id}', response_model=PromoCodeResponse)
async def update_promocode_endpoint(
promocode_id: int,
payload: PromoCodeUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoCodeResponse:
"""Update an existing promocode."""
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
_validate_update_payload(payload, promocode)
updates: dict[str, Any] = {}
if payload.code is not None:
normalized_code = payload.code.strip().upper()
if normalized_code != promocode.code:
existing = await get_promocode_by_code(db, normalized_code)
if existing and existing.id != promocode_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
updates['code'] = normalized_code
if payload.type is not None:
updates['type'] = payload.type.value
if payload.balance_bonus_kopeks is not None:
updates['balance_bonus_kopeks'] = payload.balance_bonus_kopeks
if payload.subscription_days is not None:
updates['subscription_days'] = payload.subscription_days
if payload.max_uses is not None:
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
if payload.valid_from is not None:
updates['valid_from'] = _normalize_datetime(payload.valid_from)
if payload.valid_until is not None:
updates['valid_until'] = _normalize_datetime(payload.valid_until)
if payload.is_active is not None:
updates['is_active'] = payload.is_active
if payload.first_purchase_only is not None:
updates['first_purchase_only'] = payload.first_purchase_only
if payload.promo_group_id is not None:
updates['promo_group_id'] = payload.promo_group_id
if not updates:
return _serialize_promocode(promocode)
promocode = await update_promocode(db, promocode, **updates)
return _serialize_promocode(promocode)
@router.delete(
'/{promocode_id}',
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
)
async def delete_promocode_endpoint(
promocode_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> Response:
"""Delete a promocode."""
promocode = await get_promocode_by_id(db, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo code not found')
success = await delete_promocode(db, promocode)
if not success:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Failed to delete promo code')
return Response(status_code=status.HTTP_204_NO_CONTENT)
class DeactivateDiscountResponse(BaseModel):
success: bool
message: str
deactivated_code: str | None = None
discount_percent: int = 0
user_id: int
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
async def admin_deactivate_discount_promocode(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> DeactivateDiscountResponse:
"""Admin: deactivate a user's active discount promo code."""
from app.database.crud.user import get_user_by_id as get_user
target_user = await get_user(db, user_id)
if not target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
from app.services.promocode_service import PromoCodeService
service = PromoCodeService()
result = await service.deactivate_discount_promocode(
db=db,
user_id=user_id,
admin_initiated=True,
)
if result['success']:
return DeactivateDiscountResponse(
success=True,
message=f'Discount promo code deactivated for user {user_id}',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
user_id=user_id,
)
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'User has no active discount from a promo code',
'discount_already_expired': 'Discount has already expired (cleaned up)',
'server_error': 'Server error occurred',
}
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
# ============== PromoGroup Endpoints ==============
promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo Groups'])
@promo_groups_router.get('', response_model=PromoGroupListResponse)
async def list_promo_groups(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PromoGroupListResponse:
"""Get list of all promo groups."""
total = await count_promo_groups(db)
groups_with_counts = await get_promo_groups_with_counts(
db,
offset=offset,
limit=limit,
)
return PromoGroupListResponse(
items=[_serialize_promo_group(group, members_count=count) for group, count in groups_with_counts],
total=total,
limit=limit,
offset=offset,
)
@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse)
async def get_promo_group(
group_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Get promo group details."""
group = await get_promo_group_by_id(db, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found')
members_count = await count_promo_group_members(db, group_id)
return _serialize_promo_group(group, members_count=members_count)
@promo_groups_router.post('', response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
async def create_promo_group_endpoint(
payload: PromoGroupCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Create a new promo group."""
from sqlalchemy.exc import IntegrityError
try:
group = await create_promo_group(
db,
name=payload.name,
server_discount_percent=payload.server_discount_percent,
traffic_discount_percent=payload.traffic_discount_percent,
device_discount_percent=payload.device_discount_percent,
period_discounts=payload.period_discounts,
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
apply_discounts_to_addons=payload.apply_discounts_to_addons,
is_default=payload.is_default,
)
except IntegrityError:
await db.rollback()
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
'Promo group with this name already exists',
)
return _serialize_promo_group(group, members_count=0)
@promo_groups_router.patch('/{group_id}', response_model=PromoGroupResponse)
async def update_promo_group_endpoint(
group_id: int,
payload: PromoGroupUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromoGroupResponse:
"""Update a promo group."""
from sqlalchemy.exc import IntegrityError
group = await get_promo_group_by_id(db, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found')
try:
group = await update_promo_group(
db,
group,
name=payload.name,
server_discount_percent=payload.server_discount_percent,
traffic_discount_percent=payload.traffic_discount_percent,
device_discount_percent=payload.device_discount_percent,
period_discounts=payload.period_discounts,
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
apply_discounts_to_addons=payload.apply_discounts_to_addons,
is_default=payload.is_default,
)
except IntegrityError:
await db.rollback()
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
'Promo group with this name already exists',
)
members_count = await count_promo_group_members(db, group_id)
return _serialize_promo_group(group, members_count=members_count)
@promo_groups_router.delete('/{group_id}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_promo_group_endpoint(
group_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> Response:
"""Delete a promo group."""
group = await get_promo_group_by_id(db, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Promo group not found')
success = await delete_promo_group(db, group)
if not success:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Cannot delete default promo group')
return Response(status_code=status.HTTP_204_NO_CONTENT)
-983
View File
@@ -1,983 +0,0 @@
"""Admin routes for RemnaWave management in cabinet."""
import logging
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.server_squad import (
count_active_users_for_squad,
get_all_server_squads,
get_server_squad_by_uuid,
sync_with_remnawave,
)
from app.database.models import User
from app.utils.cache import cache
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.remnawave import (
AutoSyncRunResponse,
# Auto Sync
AutoSyncStatus,
AutoSyncToggleRequest,
Bandwidth,
ConnectionStatus,
# Inbounds
InboundsListResponse,
# Migration
MigrationPreviewResponse,
MigrationRequest,
MigrationResponse,
MigrationStats,
NodeActionRequest,
NodeActionResponse,
# Nodes
NodeInfo,
NodesListResponse,
NodesOverview,
NodeStatisticsResponse,
NodeUsageResponse,
# Status & Connection
RemnaWaveStatusResponse,
ServerInfo,
SquadActionRequest,
SquadCreateRequest,
SquadDetailResponse,
SquadOperationResponse,
SquadsListResponse,
SquadUpdateRequest,
# Squads
SquadWithLocalInfo,
# Manual Sync
SyncMode,
SyncResponse,
# System Statistics
SystemStatsResponse,
SystemSummary,
TrafficPeriod,
TrafficPeriods,
)
try:
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
)
except Exception:
RemnaWaveConfigurationError = None
RemnaWaveService = None
try:
from app.services.remnawave_sync_service import remnawave_sync_service
except Exception:
remnawave_sync_service = None
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/remnawave', tags=['Cabinet Admin RemnaWave'])
# ============ Helpers ============
def _get_service() -> RemnaWaveService:
"""Get RemnaWave service instance."""
if RemnaWaveService is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='RemnaWave service is not available',
)
return RemnaWaveService()
def _ensure_configured(service: RemnaWaveService) -> None:
"""Ensure RemnaWave is configured."""
if not service.is_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=service.configuration_error or 'RemnaWave API is not configured',
)
def _parse_datetime(value: Any) -> datetime | None:
"""Parse datetime from various formats."""
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
return datetime.fromisoformat(value)
except ValueError:
return None
return None
def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
"""Serialize node data to NodeInfo model."""
return NodeInfo(
uuid=node_data.get('uuid', ''),
name=node_data.get('name', ''),
address=node_data.get('address', ''),
country_code=node_data.get('country_code'),
is_connected=bool(node_data.get('is_connected')),
is_disabled=bool(node_data.get('is_disabled')),
is_node_online=bool(node_data.get('is_node_online')),
is_xray_running=bool(node_data.get('is_xray_running')),
users_online=node_data.get('users_online'),
traffic_used_bytes=node_data.get('traffic_used_bytes'),
traffic_limit_bytes=node_data.get('traffic_limit_bytes'),
last_status_change=_parse_datetime(node_data.get('last_status_change')),
last_status_message=node_data.get('last_status_message'),
xray_uptime=node_data.get('xray_uptime'),
is_traffic_tracking_active=bool(node_data.get('is_traffic_tracking_active', False)),
traffic_reset_day=node_data.get('traffic_reset_day'),
notify_percent=node_data.get('notify_percent'),
consumption_multiplier=float(node_data.get('consumption_multiplier', 1.0)),
cpu_count=node_data.get('cpu_count'),
cpu_model=node_data.get('cpu_model'),
total_ram=node_data.get('total_ram'),
created_at=_parse_datetime(node_data.get('created_at')),
updated_at=_parse_datetime(node_data.get('updated_at')),
provider_uuid=node_data.get('provider_uuid'),
)
# ============ Status & Connection ============
@router.get('/status', response_model=RemnaWaveStatusResponse)
async def get_remnawave_status(
admin: User = Depends(get_current_admin_user),
) -> RemnaWaveStatusResponse:
"""Get RemnaWave configuration and connection status."""
service = _get_service()
connection_info: ConnectionStatus | None = None
connection_result = await service.test_api_connection()
if connection_result:
connection_info = ConnectionStatus(**connection_result)
return RemnaWaveStatusResponse(
is_configured=service.is_configured,
configuration_error=service.configuration_error,
connection=connection_info,
)
# ============ System Statistics ============
@router.get('/system', response_model=SystemStatsResponse)
async def get_system_statistics(
admin: User = Depends(get_current_admin_user),
) -> SystemStatsResponse:
"""Get full system statistics from RemnaWave."""
service = _get_service()
_ensure_configured(service)
stats = await service.get_system_statistics()
if not stats or 'system' not in stats:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Failed to get RemnaWave statistics',
)
system_data = stats.get('system', {})
server_data = stats.get('server_info', {})
bandwidth_data = stats.get('bandwidth', {})
traffic_data = stats.get('traffic_periods', {})
return SystemStatsResponse(
system=SystemSummary(
users_online=system_data.get('users_online', 0),
total_users=system_data.get('total_users', 0),
active_connections=system_data.get('active_connections', 0),
nodes_online=system_data.get('nodes_online', 0),
users_last_day=system_data.get('users_last_day', 0),
users_last_week=system_data.get('users_last_week', 0),
users_never_online=system_data.get('users_never_online', 0),
total_user_traffic=system_data.get('total_user_traffic', 0),
),
users_by_status=stats.get('users_by_status', {}),
server_info=ServerInfo(
cpu_cores=server_data.get('cpu_cores', 0),
cpu_physical_cores=server_data.get('cpu_physical_cores', 0),
memory_total=server_data.get('memory_total', 0),
memory_used=server_data.get('memory_used', 0),
memory_free=server_data.get('memory_free', 0),
memory_available=server_data.get('memory_available', 0),
uptime_seconds=server_data.get('uptime_seconds', 0),
),
bandwidth=Bandwidth(
realtime_download=bandwidth_data.get('realtime_download', 0),
realtime_upload=bandwidth_data.get('realtime_upload', 0),
realtime_total=bandwidth_data.get('realtime_total', 0),
),
traffic_periods=TrafficPeriods(
last_2_days=TrafficPeriod(**traffic_data.get('last_2_days', {'current': 0, 'previous': 0})),
last_7_days=TrafficPeriod(**traffic_data.get('last_7_days', {'current': 0, 'previous': 0})),
last_30_days=TrafficPeriod(**traffic_data.get('last_30_days', {'current': 0, 'previous': 0})),
current_month=TrafficPeriod(**traffic_data.get('current_month', {'current': 0, 'previous': 0})),
current_year=TrafficPeriod(**traffic_data.get('current_year', {'current': 0, 'previous': 0})),
),
nodes_realtime=stats.get('nodes_realtime', []),
nodes_weekly=stats.get('nodes_weekly', []),
last_updated=_parse_datetime(stats.get('last_updated')),
)
# ============ Nodes ============
@router.get('/nodes', response_model=NodesListResponse)
async def list_nodes(
admin: User = Depends(get_current_admin_user),
) -> NodesListResponse:
"""Get list of all nodes."""
service = _get_service()
_ensure_configured(service)
nodes = await service.get_all_nodes()
serialized = [_serialize_node(node) for node in nodes]
return NodesListResponse(items=serialized, total=len(serialized))
@router.get('/nodes/overview', response_model=NodesOverview)
async def get_nodes_overview(
admin: User = Depends(get_current_admin_user),
) -> NodesOverview:
"""Get nodes overview with statistics."""
service = _get_service()
_ensure_configured(service)
nodes = await service.get_all_nodes()
total = len(nodes)
online = sum(1 for n in nodes if n.get('is_connected') and not n.get('is_disabled'))
disabled = sum(1 for n in nodes if n.get('is_disabled'))
offline = total - online - disabled
total_users_online = sum(n.get('users_online', 0) or 0 for n in nodes)
return NodesOverview(
total=total,
online=online,
offline=offline,
disabled=disabled,
total_users_online=total_users_online,
nodes=[_serialize_node(n) for n in nodes],
)
@router.get('/nodes/realtime')
async def get_nodes_realtime(
admin: User = Depends(get_current_admin_user),
) -> list[dict[str, Any]]:
"""Get realtime node usage data."""
service = _get_service()
_ensure_configured(service)
return await service.get_nodes_realtime_usage()
@router.get('/nodes/{node_uuid}', response_model=NodeInfo)
async def get_node_details(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
) -> NodeInfo:
"""Get detailed information about a specific node."""
service = _get_service()
_ensure_configured(service)
node = await service.get_node_details(node_uuid)
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Node not found',
)
return _serialize_node(node)
@router.get('/nodes/{node_uuid}/statistics', response_model=NodeStatisticsResponse)
async def get_node_statistics(
node_uuid: str,
admin: User = Depends(get_current_admin_user),
) -> NodeStatisticsResponse:
"""Get node statistics with usage history."""
service = _get_service()
_ensure_configured(service)
stats = await service.get_node_statistics(node_uuid)
if not stats or not stats.get('node'):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Node not found or no statistics available',
)
return NodeStatisticsResponse(
node=_serialize_node(stats['node']),
realtime=stats.get('realtime'),
usage_history=stats.get('usage_history') or [],
last_updated=_parse_datetime(stats.get('last_updated')),
)
@router.get('/nodes/{node_uuid}/usage', response_model=NodeUsageResponse)
async def get_node_usage(
node_uuid: str,
start: datetime | None = Query(default=None),
end: datetime | None = Query(default=None),
admin: User = Depends(get_current_admin_user),
) -> NodeUsageResponse:
"""Get node usage history for a date range."""
service = _get_service()
_ensure_configured(service)
end_dt = end or datetime.utcnow()
start_dt = start or (end_dt - timedelta(days=7))
if start_dt >= end_dt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid date range',
)
usage = await service.get_node_user_usage_by_range(node_uuid, start_dt, end_dt)
return NodeUsageResponse(items=usage or [])
@router.post('/nodes/{node_uuid}/action', response_model=NodeActionResponse)
async def perform_node_action(
node_uuid: str,
payload: NodeActionRequest,
admin: User = Depends(get_current_admin_user),
) -> NodeActionResponse:
"""Perform an action on a node (enable/disable/restart)."""
service = _get_service()
_ensure_configured(service)
# Get current node state for toggle operations
if payload.action in ('enable', 'disable'):
nodes = await service.get_all_nodes()
node = next((n for n in nodes if n.get('uuid') == node_uuid), None)
if not node:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Node not found',
)
success = await service.manage_node(node_uuid, payload.action)
messages = {
'enable': 'Node enabled',
'disable': 'Node disabled',
'restart': 'Node restart initiated',
}
if success:
logger.info(f'Admin {admin.telegram_id} performed {payload.action} on node {node_uuid}')
return NodeActionResponse(
success=True,
message=messages.get(payload.action, 'Action completed'),
is_disabled=payload.action == 'disable' if payload.action in ('enable', 'disable') else None,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Failed to {payload.action} node',
)
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
async def restart_all_nodes(
admin: User = Depends(get_current_admin_user),
) -> NodeActionResponse:
"""Restart all nodes."""
service = _get_service()
_ensure_configured(service)
success = await service.restart_all_nodes()
if success:
logger.info(f'Admin {admin.telegram_id} restarted all nodes')
return NodeActionResponse(success=True, message='All nodes restart initiated')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to restart all nodes',
)
# ============ Squads (Internal Squads) ============
@router.get('/squads', response_model=SquadsListResponse)
async def list_squads(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SquadsListResponse:
"""Get list of all squads with local database info."""
service = _get_service()
_ensure_configured(service)
# Get squads from RemnaWave
rw_squads = await service.get_all_squads()
# Get local squads from DB
local_squads, _ = await get_all_server_squads(db, page=1, limit=1000)
local_by_uuid = {s.squad_uuid: s for s in local_squads}
items = []
for squad in rw_squads:
local = local_by_uuid.get(squad.get('uuid'))
items.append(
SquadWithLocalInfo(
uuid=squad.get('uuid', ''),
name=squad.get('name', ''),
members_count=squad.get('members_count', 0),
inbounds_count=squad.get('inbounds_count', 0),
inbounds=squad.get('inbounds', []),
local_id=local.id if local else None,
display_name=local.display_name if local else None,
country_code=local.country_code if local else None,
is_available=local.is_available if local else None,
is_trial_eligible=local.is_trial_eligible if local else None,
price_kopeks=local.price_kopeks if local else None,
max_users=local.max_users if local else None,
current_users=local.current_users if local else None,
is_synced=local is not None,
)
)
return SquadsListResponse(items=items, total=len(items))
@router.get('/squads/{squad_uuid}', response_model=SquadDetailResponse)
async def get_squad_details(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SquadDetailResponse:
"""Get detailed information about a squad."""
service = _get_service()
_ensure_configured(service)
# Get squad from RemnaWave
squad = await service.get_squad_details(squad_uuid)
if not squad:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Squad not found',
)
# Get local info from DB
local = await get_server_squad_by_uuid(db, squad_uuid)
active_subs = await count_active_users_for_squad(db, squad_uuid) if local else 0
return SquadDetailResponse(
uuid=squad.get('uuid', ''),
name=squad.get('name', ''),
members_count=squad.get('members_count', 0),
inbounds_count=squad.get('inbounds_count', 0),
inbounds=squad.get('inbounds', []),
local_id=local.id if local else None,
display_name=local.display_name if local else None,
country_code=local.country_code if local else None,
description=local.description if local else None,
is_available=local.is_available if local else None,
is_trial_eligible=local.is_trial_eligible if local else None,
price_kopeks=local.price_kopeks if local else None,
max_users=local.max_users if local else None,
current_users=local.current_users if local else None,
sort_order=local.sort_order if local else None,
is_synced=local is not None,
active_subscriptions=active_subs,
)
@router.post('/squads', response_model=SquadOperationResponse, status_code=status.HTTP_201_CREATED)
async def create_squad(
payload: SquadCreateRequest,
admin: User = Depends(get_current_admin_user),
) -> SquadOperationResponse:
"""Create a new squad in RemnaWave."""
service = _get_service()
_ensure_configured(service)
squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids)
if squad_uuid:
logger.info(f'Admin {admin.telegram_id} created squad {payload.name} ({squad_uuid})')
return SquadOperationResponse(
success=True,
message='Squad created successfully',
data={'uuid': squad_uuid},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to create squad',
)
@router.patch('/squads/{squad_uuid}', response_model=SquadOperationResponse)
async def update_squad(
squad_uuid: str,
payload: SquadUpdateRequest,
admin: User = Depends(get_current_admin_user),
) -> SquadOperationResponse:
"""Update a squad in RemnaWave."""
service = _get_service()
_ensure_configured(service)
if payload.name is None and payload.inbound_uuids is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No update data provided',
)
success = await service.update_squad(
squad_uuid,
name=payload.name,
inbounds=payload.inbound_uuids,
)
if success:
logger.info(f'Admin {admin.telegram_id} updated squad {squad_uuid}')
return SquadOperationResponse(success=True, message='Squad updated')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to update squad',
)
@router.post('/squads/{squad_uuid}/action', response_model=SquadOperationResponse)
async def perform_squad_action(
squad_uuid: str,
payload: SquadActionRequest,
admin: User = Depends(get_current_admin_user),
) -> SquadOperationResponse:
"""Perform an action on a squad."""
service = _get_service()
_ensure_configured(service)
action = payload.action
success = False
message = 'Unknown action'
if action == 'add_all_users':
success = await service.add_all_users_to_squad(squad_uuid)
message = 'Users added' if success else 'Failed to add users'
elif action == 'remove_all_users':
success = await service.remove_all_users_from_squad(squad_uuid)
message = 'Users removed' if success else 'Failed to remove users'
elif action == 'delete':
success = await service.delete_squad(squad_uuid)
message = 'Squad deleted' if success else 'Failed to delete squad'
elif action == 'rename':
if not payload.name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Name is required for rename action',
)
success = await service.rename_squad(squad_uuid, payload.name)
message = 'Squad renamed' if success else 'Failed to rename squad'
elif action == 'update_inbounds':
if not payload.inbound_uuids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Inbound UUIDs are required',
)
success = await service.update_squad_inbounds(squad_uuid, payload.inbound_uuids)
message = 'Inbounds updated' if success else 'Failed to update inbounds'
if success:
logger.info(f'Admin {admin.telegram_id} performed {action} on squad {squad_uuid}')
return SquadOperationResponse(success=success, message=message)
@router.delete('/squads/{squad_uuid}', response_model=SquadOperationResponse)
async def delete_squad(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
) -> SquadOperationResponse:
"""Delete a squad."""
service = _get_service()
_ensure_configured(service)
success = await service.delete_squad(squad_uuid)
if success:
logger.info(f'Admin {admin.telegram_id} deleted squad {squad_uuid}')
return SquadOperationResponse(success=True, message='Squad deleted')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to delete squad',
)
# ============ Migration ============
@router.get('/squads/{squad_uuid}/migration-preview', response_model=MigrationPreviewResponse)
async def preview_migration(
squad_uuid: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> MigrationPreviewResponse:
"""Get migration preview for a squad."""
squad = await get_server_squad_by_uuid(db, squad_uuid)
if not squad:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Squad not found in local database',
)
users_to_migrate = await count_active_users_for_squad(db, squad_uuid)
return MigrationPreviewResponse(
squad_uuid=squad.squad_uuid,
squad_name=squad.display_name,
current_users=squad.current_users or 0,
max_users=squad.max_users,
users_to_migrate=users_to_migrate,
)
@router.post('/squads/migrate', response_model=MigrationResponse)
async def migrate_squad_users(
payload: MigrationRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> MigrationResponse:
"""Migrate users from one squad to another."""
service = _get_service()
_ensure_configured(service)
source_uuid = payload.source_uuid.strip()
target_uuid = payload.target_uuid.strip()
if source_uuid == target_uuid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Source and target squads must be different',
)
source = await get_server_squad_by_uuid(db, source_uuid)
if not source:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Source squad not found',
)
target = await get_server_squad_by_uuid(db, target_uuid)
if not target:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Target squad not found',
)
try:
result = await service.migrate_squad_users(
db,
source_uuid=source.squad_uuid,
target_uuid=target.squad_uuid,
)
except RemnaWaveConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
)
if not result.get('success'):
return MigrationResponse(
success=False,
message=result.get('message') or 'Migration failed',
error=result.get('error'),
)
logger.info(f'Admin {admin.telegram_id} migrated users from {source_uuid} to {target_uuid}')
return MigrationResponse(
success=True,
message=result.get('message') or 'Migration completed',
data=MigrationStats(
source_uuid=source.squad_uuid,
target_uuid=target.squad_uuid,
total=result.get('total', 0),
updated=result.get('updated', 0),
panel_updated=result.get('panel_updated', 0),
panel_failed=result.get('panel_failed', 0),
source_removed=result.get('source_removed', 0),
target_added=result.get('target_added', 0),
),
)
# ============ Inbounds ============
@router.get('/inbounds', response_model=InboundsListResponse)
async def list_inbounds(
admin: User = Depends(get_current_admin_user),
) -> InboundsListResponse:
"""Get list of all available inbounds."""
service = _get_service()
_ensure_configured(service)
inbounds = await service.get_all_inbounds()
return InboundsListResponse(items=inbounds or [], total=len(inbounds or []))
# ============ Auto Sync ============
@router.get('/sync/auto/status', response_model=AutoSyncStatus)
async def get_auto_sync_status(
admin: User = Depends(get_current_admin_user),
) -> AutoSyncStatus:
"""Get auto sync status."""
if remnawave_sync_service is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Auto sync service is not available',
)
status_obj = remnawave_sync_service.get_status()
return AutoSyncStatus(
enabled=status_obj.enabled,
times=[t.strftime('%H:%M') for t in status_obj.times] if status_obj.times else [],
next_run=status_obj.next_run,
is_running=status_obj.is_running,
last_run_started_at=status_obj.last_run_started_at,
last_run_finished_at=status_obj.last_run_finished_at,
last_run_success=status_obj.last_run_success,
last_run_reason=status_obj.last_run_reason,
last_run_error=status_obj.last_run_error,
last_user_stats=status_obj.last_user_stats,
last_server_stats=status_obj.last_server_stats,
)
@router.post('/sync/auto/toggle', response_model=SyncResponse)
async def toggle_auto_sync(
payload: AutoSyncToggleRequest,
admin: User = Depends(get_current_admin_user),
) -> SyncResponse:
"""Toggle auto sync on/off."""
if remnawave_sync_service is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Auto sync service is not available',
)
# This would need to update settings - for now just return info
# In production, this should update REMNAWAVE_AUTO_SYNC_ENABLED setting
current_status = remnawave_sync_service.get_status()
if payload.enabled and not current_status.enabled:
# Enable - would need to update settings and refresh schedule
remnawave_sync_service.schedule_refresh(run_immediately=True)
logger.info(f'Admin {admin.telegram_id} enabled auto sync')
return SyncResponse(
success=True,
message='Auto sync enabled and scheduled',
)
if not payload.enabled and current_status.enabled:
# Disable - would need to update settings and stop scheduler
logger.info(f'Admin {admin.telegram_id} disabled auto sync')
return SyncResponse(
success=True,
message='Auto sync setting change requested. Restart may be required.',
)
return SyncResponse(
success=True,
message='No change needed',
)
@router.post('/sync/auto/run', response_model=AutoSyncRunResponse)
async def run_auto_sync_now(
admin: User = Depends(get_current_admin_user),
) -> AutoSyncRunResponse:
"""Run auto sync immediately."""
if remnawave_sync_service is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Auto sync service is not available',
)
logger.info(f'Admin {admin.telegram_id} triggered manual sync')
result = await remnawave_sync_service.run_sync_now(reason='manual')
return AutoSyncRunResponse(
started=result.get('started', False),
success=result.get('success'),
error=result.get('error'),
user_stats=result.get('user_stats'),
server_stats=result.get('server_stats'),
reason='manual',
)
# ============ Manual Sync ============
@router.post('/sync/from-panel', response_model=SyncResponse)
async def sync_from_panel(
payload: SyncMode,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync users from RemnaWave panel to bot."""
service = _get_service()
_ensure_configured(service)
try:
stats = await service.sync_users_from_panel(db, payload.mode)
logger.info(f'Admin {admin.telegram_id} synced from panel (mode: {payload.mode})')
return SyncResponse(
success=True,
message='Sync from panel completed',
data=stats,
)
except RemnaWaveConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=str(exc),
)
@router.post('/sync/to-panel', response_model=SyncResponse)
async def sync_to_panel(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync users from bot to RemnaWave panel."""
service = _get_service()
_ensure_configured(service)
stats = await service.sync_users_to_panel(db)
logger.info(f'Admin {admin.telegram_id} synced to panel')
return SyncResponse(
success=True,
message='Sync to panel completed',
data=stats,
)
@router.post('/sync/servers', response_model=SyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync servers/squads from RemnaWave."""
service = _get_service()
_ensure_configured(service)
squads = await service.get_all_squads()
if not squads:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail='Failed to get squads from RemnaWave',
)
created, updated, removed = await sync_with_remnawave(db, squads)
try:
await cache.delete_pattern('available_countries*')
except Exception as e:
logger.warning(f'Failed to clear countries cache: {e}')
logger.info(f'Admin {admin.telegram_id} synced servers: created={created}, updated={updated}, removed={removed}')
return SyncResponse(
success=True,
message='Servers synced successfully',
data={
'created': created,
'updated': updated,
'removed': removed,
'total': len(squads),
},
)
@router.post('/sync/subscriptions/validate', response_model=SyncResponse)
async def validate_subscriptions(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Validate and fix subscriptions."""
service = _get_service()
_ensure_configured(service)
stats = await service.validate_and_fix_subscriptions(db)
logger.info(f'Admin {admin.telegram_id} validated subscriptions')
return SyncResponse(
success=True,
message='Subscriptions validated',
data=stats,
)
@router.post('/sync/subscriptions/cleanup', response_model=SyncResponse)
async def cleanup_subscriptions(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Cleanup orphaned subscriptions."""
service = _get_service()
_ensure_configured(service)
stats = await service.cleanup_orphaned_subscriptions(db)
logger.info(f'Admin {admin.telegram_id} cleaned up subscriptions')
return SyncResponse(
success=True,
message='Cleanup completed',
data=stats,
)
@router.post('/sync/subscriptions/statuses', response_model=SyncResponse)
async def sync_subscription_statuses(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Sync subscription statuses."""
service = _get_service()
_ensure_configured(service)
stats = await service.sync_subscription_statuses(db)
logger.info(f'Admin {admin.telegram_id} synced subscription statuses')
return SyncResponse(
success=True,
message='Subscription statuses synced',
data=stats,
)
@router.get('/sync/recommendations', response_model=SyncResponse)
async def get_sync_recommendations(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> SyncResponse:
"""Get sync recommendations."""
service = _get_service()
_ensure_configured(service)
data = await service.get_sync_recommendations(db)
return SyncResponse(
success=True,
message='Recommendations retrieved',
data=data,
)
-330
View File
@@ -1,330 +0,0 @@
"""Admin routes for managing servers in cabinet."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import String, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.server_squad import (
count_active_users_for_squad,
get_all_server_squads,
get_server_squad_by_id,
sync_with_remnawave,
update_server_squad,
update_server_squad_promo_groups,
)
from app.database.models import PromoGroup, ServerSquad, Subscription, Tariff, User
from app.services.subscription_service import SubscriptionService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.servers import (
PromoGroupInfo,
ServerDetailResponse,
ServerListItem,
ServerListResponse,
ServerStatsResponse,
ServerSyncResponse,
ServerToggleResponse,
ServerTrialToggleResponse,
ServerUpdateRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/servers', tags=['Cabinet Admin Servers'])
async def _get_server_promo_groups(db: AsyncSession, server: ServerSquad) -> list[PromoGroupInfo]:
"""Get promo group info for server."""
result = await db.execute(select(PromoGroup).order_by(PromoGroup.name))
all_groups = result.scalars().all()
selected_ids = {pg.id for pg in server.allowed_promo_groups} if server.allowed_promo_groups else set()
return [
PromoGroupInfo(
id=pg.id,
name=pg.name,
is_selected=pg.id in selected_ids,
)
for pg in all_groups
]
async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> list[str]:
"""Get list of tariff names using this server."""
# Get all tariffs and filter in Python since JSON array queries are DB-specific
result = await db.execute(select(Tariff.name, Tariff.allowed_squads))
tariff_names = []
for name, allowed_squads in result.fetchall():
if allowed_squads and squad_uuid in allowed_squads:
tariff_names.append(name)
return tariff_names
@router.get('', response_model=ServerListResponse)
async def list_servers(
include_unavailable: bool = True,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers."""
servers, total = await get_all_server_squads(
db,
available_only=not include_unavailable,
)
items = []
for server in servers:
items.append(
ServerListItem(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
original_name=server.original_name,
country_code=server.country_code,
is_available=server.is_available,
is_trial_eligible=server.is_trial_eligible,
price_kopeks=server.price_kopeks,
price_rubles=server.price_kopeks / 100,
max_users=server.max_users,
current_users=server.current_users or 0,
sort_order=server.sort_order,
is_full=server.is_full,
availability_status=server.availability_status,
created_at=server.created_at,
)
)
return ServerListResponse(servers=items, total=total)
@router.get('/{server_id}', response_model=ServerDetailResponse)
async def get_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed server info."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Server not found',
)
promo_groups = await _get_server_promo_groups(db, server)
tariffs_using = await _get_tariffs_using_server(db, server.squad_uuid)
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
return ServerDetailResponse(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
original_name=server.original_name,
country_code=server.country_code,
description=server.description,
is_available=server.is_available,
is_trial_eligible=server.is_trial_eligible,
price_kopeks=server.price_kopeks,
price_rubles=server.price_kopeks / 100,
max_users=server.max_users,
current_users=server.current_users or 0,
sort_order=server.sort_order,
is_full=server.is_full,
availability_status=server.availability_status,
promo_groups=promo_groups,
active_subscriptions=active_subs,
tariffs_using=tariffs_using,
created_at=server.created_at,
updated_at=server.updated_at,
)
@router.put('/{server_id}', response_model=ServerDetailResponse)
async def update_existing_server(
server_id: int,
request: ServerUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing server."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Server not found',
)
# Build updates dict
updates = {}
if request.display_name is not None:
updates['display_name'] = request.display_name
if request.description is not None:
updates['description'] = request.description
if request.country_code is not None:
updates['country_code'] = request.country_code
if request.is_available is not None:
updates['is_available'] = request.is_available
if request.is_trial_eligible is not None:
updates['is_trial_eligible'] = request.is_trial_eligible
if request.price_kopeks is not None:
updates['price_kopeks'] = request.price_kopeks
if request.max_users is not None:
updates['max_users'] = request.max_users if request.max_users > 0 else None
if request.sort_order is not None:
updates['sort_order'] = request.sort_order
if updates:
await update_server_squad(db, server_id, **updates)
# Update promo groups separately
if request.promo_group_ids is not None:
await update_server_squad_promo_groups(db, server_id, request.promo_group_ids)
logger.info(f'Admin {admin.id} updated server {server_id}')
return await get_server(server_id, admin, db)
@router.post('/{server_id}/toggle', response_model=ServerToggleResponse)
async def toggle_server(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server availability."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Server not found',
)
new_status = not server.is_available
await update_server_squad(db, server_id, is_available=new_status)
status_text = 'enabled' if new_status else 'disabled'
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
return ServerToggleResponse(
id=server_id,
is_available=new_status,
message=f'Server {status_text}',
)
@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse)
async def toggle_server_trial(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle server trial eligibility."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Server not found',
)
new_status = not server.is_trial_eligible
await update_server_squad(db, server_id, is_trial_eligible=new_status)
status_text = 'enabled for trial' if new_status else 'disabled for trial'
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
return ServerTrialToggleResponse(
id=server_id,
is_trial_eligible=new_status,
message=f'Server {status_text}',
)
@router.get('/{server_id}/stats', response_model=ServerStatsResponse)
async def get_server_stats(
server_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get server statistics."""
server = await get_server_squad_by_id(db, server_id)
if not server:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Server not found',
)
active_subs = await count_active_users_for_squad(db, server.squad_uuid)
# Count trial subscriptions on this server
# Use LIKE query for JSON array since .contains() is DB-specific
trial_result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.is_trial == True,
Subscription.status == 'active',
func.cast(Subscription.connected_squads, String).like(f'%"{server.squad_uuid}"%'),
)
)
trial_count = trial_result.scalar() or 0
usage_percent = None
if server.max_users and server.max_users > 0:
usage_percent = round((server.current_users or 0) / server.max_users * 100, 1)
return ServerStatsResponse(
id=server_id,
display_name=server.display_name,
squad_uuid=server.squad_uuid,
current_users=server.current_users or 0,
max_users=server.max_users,
active_subscriptions=active_subs,
trial_subscriptions=trial_count,
usage_percent=usage_percent,
)
@router.post('/sync', response_model=ServerSyncResponse)
async def sync_servers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Sync servers with RemnaWave."""
try:
subscription_service = SubscriptionService()
if not subscription_service.is_configured:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='RemnaWave is not configured',
)
# Get squads from RemnaWave
squads = await subscription_service.get_remnawave_squads()
if squads is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to fetch squads from RemnaWave',
)
# Sync with database
created, updated, removed = await sync_with_remnawave(db, squads)
logger.info(f'Admin {admin.id} synced servers: +{created} ~{updated} -{removed}')
return ServerSyncResponse(
created=created,
updated=updated,
removed=removed,
message=f'Synced: {created} created, {updated} updated, {removed} removed',
)
except HTTPException:
raise
except Exception as e:
logger.error(f'Failed to sync servers: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f'Sync failed: {e!s}',
)
-274
View File
@@ -1,274 +0,0 @@
"""Admin settings routes for cabinet - system configuration management."""
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.system_settings_service import (
ReadOnlySettingError,
bot_configuration_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/settings', tags=['Admin Settings'])
# ============ Schemas ============
class SettingCategoryRef(BaseModel):
"""Reference to category."""
key: str
label: str
class SettingCategorySummary(BaseModel):
"""Category summary."""
key: str
label: str
description: str = ''
items: int
class SettingChoice(BaseModel):
"""Choice option for setting."""
value: Any
label: str
description: str | None = None
class SettingHint(BaseModel):
"""Setting hints and guidance."""
description: str = ''
format: str = ''
example: str = ''
warning: str = ''
class SettingDefinition(BaseModel):
"""Full setting definition with current state."""
key: str
name: str
category: SettingCategoryRef
type: str
is_optional: bool
current: Any = Field(default=None)
original: Any = Field(default=None)
has_override: bool
read_only: bool = Field(default=False)
choices: list[SettingChoice] = Field(default_factory=list)
hint: SettingHint | None = None
class SettingUpdateRequest(BaseModel):
"""Request to update setting value."""
value: Any
# ============ Helper Functions ============
def _coerce_value(key: str, value: Any) -> Any:
"""Convert and validate value for a setting."""
definition = bot_configuration_service.get_definition(key)
if value is None:
if definition.is_optional:
return None
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Value is required')
python_type = definition.python_type
try:
if python_type is bool:
if isinstance(value, bool):
normalized = value
elif isinstance(value, str):
lowered = value.strip().lower()
if lowered in {'true', '1', 'yes', 'on', 'да'}:
normalized = True
elif lowered in {'false', '0', 'no', 'off', 'нет'}:
normalized = False
else:
raise ValueError('invalid bool')
else:
raise ValueError('invalid bool')
elif python_type is int:
normalized = int(value)
elif python_type is float:
normalized = float(value)
else:
normalized = str(value)
except ValueError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Invalid value type') from None
choices = bot_configuration_service.get_choice_options(key)
if choices:
allowed_values = {option.value for option in choices}
if normalized not in allowed_values:
readable = ', '.join(bot_configuration_service.format_value(opt.value) for opt in choices)
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f'Value must be one of: {readable}',
)
return normalized
def _serialize_definition(definition, include_choices: bool = True) -> SettingDefinition:
"""Serialize setting definition to response model."""
current = bot_configuration_service.get_current_value(definition.key)
original = bot_configuration_service.get_original_value(definition.key)
has_override = bot_configuration_service.has_override(definition.key)
choices: list[SettingChoice] = []
if include_choices:
choices = [
SettingChoice(
value=option.value,
label=option.label,
description=option.description,
)
for option in bot_configuration_service.get_choice_options(definition.key)
]
# Get setting hints
guidance = bot_configuration_service.get_setting_guidance(definition.key)
hint = SettingHint(
description=guidance.get('description', ''),
format=guidance.get('format', ''),
example=guidance.get('example', ''),
warning=guidance.get('warning', ''),
)
return SettingDefinition(
key=definition.key,
name=definition.display_name,
category=SettingCategoryRef(
key=definition.category_key,
label=definition.category_label,
),
type=definition.type_label,
is_optional=definition.is_optional,
current=current,
original=original,
has_override=has_override,
read_only=bot_configuration_service.is_read_only(definition.key),
choices=choices,
hint=hint,
)
# ============ Routes ============
@router.get('/categories', response_model=list[SettingCategorySummary])
async def list_categories(
admin: User = Depends(get_current_admin_user),
):
"""Get list of setting categories."""
categories = bot_configuration_service.get_categories()
return [
SettingCategorySummary(
key=key,
label=label,
description=bot_configuration_service.get_category_description(key),
items=count,
)
for key, label, count in categories
]
@router.get('', response_model=list[SettingDefinition])
async def list_settings(
admin: User = Depends(get_current_admin_user),
category: str | None = Query(default=None, alias='category_key'),
):
"""Get list of all settings or settings for a specific category."""
items: list[SettingDefinition] = []
if category:
definitions = bot_configuration_service.get_settings_for_category(category)
items.extend(_serialize_definition(defn) for defn in definitions)
return items
for category_key, _, _ in bot_configuration_service.get_categories():
definitions = bot_configuration_service.get_settings_for_category(category_key)
items.extend(_serialize_definition(defn) for defn in definitions)
return items
@router.get('/{key}', response_model=SettingDefinition)
async def get_setting(
key: str,
admin: User = Depends(get_current_admin_user),
):
"""Get a specific setting by key."""
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
return _serialize_definition(definition)
@router.put('/{key}', response_model=SettingDefinition)
async def update_setting(
key: str,
payload: SettingUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update a setting value."""
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
value = _coerce_value(key, payload.value)
try:
await bot_configuration_service.set_value(db, key, value)
except ReadOnlySettingError as error:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
logger.info(f'Admin {admin.telegram_id} updated setting {key} to {value}')
return _serialize_definition(definition)
@router.delete('/{key}', response_model=SettingDefinition)
async def reset_setting(
key: str,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset a setting to its default value."""
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Setting not found') from error
try:
await bot_configuration_service.reset_value(db, key)
except ReadOnlySettingError as error:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
await db.commit()
logger.info(f'Admin {admin.telegram_id} reset setting {key}')
return _serialize_definition(definition)
File diff suppressed because it is too large Load Diff
-551
View File
@@ -1,551 +0,0 @@
"""Admin routes for managing tariffs in cabinet."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.server_squad import get_all_server_squads
from app.database.crud.tariff import (
create_tariff,
delete_tariff,
get_all_tariffs,
get_tariff_by_id,
get_tariff_subscriptions_count,
load_period_prices_from_db,
reorder_tariffs,
set_tariff_promo_groups,
update_tariff,
)
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tariffs import (
PeriodPrice,
PromoGroupInfo,
ServerInfo,
ServerTrafficLimit,
TariffCreateRequest,
TariffDetailResponse,
TariffListItem,
TariffListResponse,
TariffSortOrderRequest,
TariffStatsResponse,
TariffToggleResponse,
TariffTrialResponse,
TariffUpdateRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/tariffs', tags=['Cabinet Admin Tariffs'])
async def _get_tariff_servers(
db: AsyncSession, allowed_squads: list[str], server_traffic_limits: dict = None
) -> list[ServerInfo]:
"""Get server info for tariff."""
servers, _ = await get_all_server_squads(db, available_only=False)
limits = server_traffic_limits or {}
result = []
for server in servers:
# Получаем индивидуальный лимит трафика для сервера
server_limit = None
if server.squad_uuid in limits:
limit_data = limits[server.squad_uuid]
if isinstance(limit_data, dict) and 'traffic_limit_gb' in limit_data:
server_limit = limit_data['traffic_limit_gb']
elif isinstance(limit_data, int):
server_limit = limit_data
result.append(
ServerInfo(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
country_code=server.country_code,
is_selected=server.squad_uuid in allowed_squads,
traffic_limit_gb=server_limit,
)
)
return result
async def _get_tariff_promo_groups(db: AsyncSession, tariff: Tariff) -> list[PromoGroupInfo]:
"""Get promo group info for tariff."""
result = await db.execute(select(PromoGroup).order_by(PromoGroup.name))
all_groups = result.scalars().all()
selected_ids = {pg.id for pg in tariff.allowed_promo_groups} if tariff.allowed_promo_groups else set()
return [
PromoGroupInfo(
id=pg.id,
name=pg.name,
is_selected=pg.id in selected_ids,
)
for pg in all_groups
]
def _period_prices_to_list(period_prices: dict) -> list[PeriodPrice]:
"""Convert period_prices dict to list."""
if not period_prices:
return []
return [
PeriodPrice(days=int(days), price_kopeks=price)
for days, price in sorted(period_prices.items(), key=lambda x: int(x[0]))
]
def _period_prices_to_dict(period_prices: list[PeriodPrice]) -> dict:
"""Convert period_prices list to dict."""
return {str(pp.days): pp.price_kopeks for pp in period_prices}
@router.get('', response_model=TariffListResponse)
async def list_tariffs(
include_inactive: bool = True,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all tariffs."""
tariffs = await get_all_tariffs(db, include_inactive=include_inactive)
items = []
for tariff in tariffs:
subs_count = await get_tariff_subscriptions_count(db, tariff.id)
items.append(
TariffListItem(
id=tariff.id,
name=tariff.name,
description=tariff.description,
is_active=tariff.is_active,
is_trial_available=tariff.is_trial_available,
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
allow_traffic_topup=tariff.allow_traffic_topup,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
tier_level=tariff.tier_level,
display_order=tariff.display_order,
servers_count=len(tariff.allowed_squads or []),
subscriptions_count=subs_count,
created_at=tariff.created_at,
)
)
return TariffListResponse(tariffs=items, total=len(items))
@router.get('/available-servers', response_model=list[ServerInfo])
async def get_available_servers(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of all servers for tariff selection."""
servers, _ = await get_all_server_squads(db, available_only=False)
return [
ServerInfo(
id=server.id,
squad_uuid=server.squad_uuid,
display_name=server.display_name,
country_code=server.country_code,
is_selected=False,
)
for server in servers
]
@router.put('/order')
async def update_tariff_order(
request: TariffSortOrderRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the display order of tariffs."""
await reorder_tariffs(db, request.tariff_ids)
await db.commit()
logger.info(f'Admin {admin.id} updated tariff order: {request.tariff_ids}')
return {'message': 'Tariff order updated successfully'}
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
async def get_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed tariff info."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
allowed_squads = tariff.allowed_squads or []
server_traffic_limits = tariff.server_traffic_limits or {}
servers = await _get_tariff_servers(db, allowed_squads, server_traffic_limits)
promo_groups = await _get_tariff_promo_groups(db, tariff)
subs_count = await get_tariff_subscriptions_count(db, tariff.id)
# Преобразуем server_traffic_limits в формат для схемы
server_limits_response = {}
for uuid, limit_data in server_traffic_limits.items():
if isinstance(limit_data, dict):
server_limits_response[uuid] = ServerTrafficLimit(**limit_data)
elif isinstance(limit_data, int):
server_limits_response[uuid] = ServerTrafficLimit(traffic_limit_gb=limit_data)
return TariffDetailResponse(
id=tariff.id,
name=tariff.name,
description=tariff.description,
is_active=tariff.is_active,
is_trial_available=tariff.is_trial_available,
allow_traffic_topup=tariff.allow_traffic_topup,
traffic_topup_enabled=tariff.traffic_topup_enabled,
traffic_topup_packages=tariff.traffic_topup_packages or {},
max_topup_traffic_gb=tariff.max_topup_traffic_gb,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
device_price_kopeks=tariff.device_price_kopeks,
max_device_limit=tariff.max_device_limit,
tier_level=tariff.tier_level,
display_order=tariff.display_order,
period_prices=_period_prices_to_list(tariff.period_prices),
allowed_squads=allowed_squads,
server_traffic_limits=server_limits_response,
servers=servers,
promo_groups=promo_groups,
subscriptions_count=subs_count,
# Произвольное количество дней
custom_days_enabled=tariff.custom_days_enabled,
price_per_day_kopeks=tariff.price_per_day_kopeks,
min_days=tariff.min_days,
max_days=tariff.max_days,
# Произвольный трафик при покупке
custom_traffic_enabled=tariff.custom_traffic_enabled,
traffic_price_per_gb_kopeks=tariff.traffic_price_per_gb_kopeks,
min_traffic_gb=tariff.min_traffic_gb,
max_traffic_gb=tariff.max_traffic_gb,
# Дневной тариф
is_daily=tariff.is_daily,
daily_price_kopeks=tariff.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=tariff.traffic_reset_mode,
created_at=tariff.created_at,
updated_at=tariff.updated_at,
)
@router.post('', response_model=TariffDetailResponse)
async def create_new_tariff(
request: TariffCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new tariff."""
period_prices_dict = _period_prices_to_dict(request.period_prices)
# Преобразуем ServerTrafficLimit в dict для хранения
server_limits_dict = (
{uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()}
if request.server_traffic_limits
else {}
)
tariff = await create_tariff(
db=db,
name=request.name,
description=request.description,
is_active=request.is_active,
allow_traffic_topup=request.allow_traffic_topup,
traffic_topup_enabled=request.traffic_topup_enabled,
traffic_topup_packages=request.traffic_topup_packages,
max_topup_traffic_gb=request.max_topup_traffic_gb,
traffic_limit_gb=request.traffic_limit_gb,
device_limit=request.device_limit,
device_price_kopeks=request.device_price_kopeks,
max_device_limit=request.max_device_limit,
tier_level=request.tier_level,
period_prices=period_prices_dict,
allowed_squads=request.allowed_squads,
server_traffic_limits=server_limits_dict,
promo_group_ids=request.promo_group_ids if request.promo_group_ids else None,
# Произвольное количество дней
custom_days_enabled=request.custom_days_enabled,
price_per_day_kopeks=request.price_per_day_kopeks,
min_days=request.min_days,
max_days=request.max_days,
# Произвольный трафик при покупке
custom_traffic_enabled=request.custom_traffic_enabled,
traffic_price_per_gb_kopeks=request.traffic_price_per_gb_kopeks,
min_traffic_gb=request.min_traffic_gb,
max_traffic_gb=request.max_traffic_gb,
# Дневной тариф
is_daily=request.is_daily,
daily_price_kopeks=request.daily_price_kopeks,
# Режим сброса трафика
traffic_reset_mode=request.traffic_reset_mode,
)
logger.info(f'Admin {admin.id} created tariff {tariff.id}: {tariff.name}')
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
# Return full detail
return await get_tariff(tariff.id, admin, db)
@router.put('/{tariff_id}', response_model=TariffDetailResponse)
async def update_existing_tariff(
tariff_id: int,
request: TariffUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update an existing tariff."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
# Build updates dict
updates = {}
if request.name is not None:
updates['name'] = request.name
if request.description is not None:
updates['description'] = request.description
if request.is_active is not None:
updates['is_active'] = request.is_active
if request.allow_traffic_topup is not None:
updates['allow_traffic_topup'] = request.allow_traffic_topup
if request.traffic_topup_enabled is not None:
updates['traffic_topup_enabled'] = request.traffic_topup_enabled
if request.traffic_topup_packages is not None:
updates['traffic_topup_packages'] = request.traffic_topup_packages
if request.max_topup_traffic_gb is not None:
updates['max_topup_traffic_gb'] = request.max_topup_traffic_gb
if request.traffic_limit_gb is not None:
updates['traffic_limit_gb'] = request.traffic_limit_gb
if request.device_limit is not None:
updates['device_limit'] = request.device_limit
if request.device_price_kopeks is not None:
updates['device_price_kopeks'] = request.device_price_kopeks
if request.max_device_limit is not None:
updates['max_device_limit'] = request.max_device_limit
if request.tier_level is not None:
updates['tier_level'] = request.tier_level
if request.display_order is not None:
updates['display_order'] = request.display_order
if request.period_prices is not None:
updates['period_prices'] = _period_prices_to_dict(request.period_prices)
if request.allowed_squads is not None:
updates['allowed_squads'] = request.allowed_squads
if request.server_traffic_limits is not None:
# Преобразуем ServerTrafficLimit в dict для хранения
updates['server_traffic_limits'] = {
uuid: limit.model_dump() for uuid, limit in request.server_traffic_limits.items()
}
# Произвольное количество дней
if request.custom_days_enabled is not None:
updates['custom_days_enabled'] = request.custom_days_enabled
if request.price_per_day_kopeks is not None:
updates['price_per_day_kopeks'] = request.price_per_day_kopeks
if request.min_days is not None:
updates['min_days'] = request.min_days
if request.max_days is not None:
updates['max_days'] = request.max_days
# Произвольный трафик при покупке
if request.custom_traffic_enabled is not None:
updates['custom_traffic_enabled'] = request.custom_traffic_enabled
if request.traffic_price_per_gb_kopeks is not None:
updates['traffic_price_per_gb_kopeks'] = request.traffic_price_per_gb_kopeks
if request.min_traffic_gb is not None:
updates['min_traffic_gb'] = request.min_traffic_gb
if request.max_traffic_gb is not None:
updates['max_traffic_gb'] = request.max_traffic_gb
# Дневной тариф
if request.is_daily is not None:
updates['is_daily'] = request.is_daily
if request.daily_price_kopeks is not None:
updates['daily_price_kopeks'] = request.daily_price_kopeks
# Режим сброса трафика (None допускается как значение для сброса к глобальной настройке)
if 'traffic_reset_mode' in request.model_fields_set:
updates['traffic_reset_mode'] = request.traffic_reset_mode
if updates:
await update_tariff(db, tariff, **updates)
# Update promo groups separately
if request.promo_group_ids is not None:
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
return await get_tariff(tariff_id, admin, db)
@router.delete('/{tariff_id}')
async def delete_existing_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete a tariff."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
await delete_tariff(db, tariff)
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})')
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
async def toggle_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff active status."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
new_status = not tariff.is_active
await update_tariff(db, tariff, is_active=new_status)
status_text = 'activated' if new_status else 'deactivated'
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
return TariffToggleResponse(
id=tariff_id,
is_active=new_status,
message=f'Tariff {status_text}',
)
@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse)
async def toggle_trial_tariff(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Toggle tariff trial availability.
When enabling trial on a tariff, removes trial flag from all other tariffs
(only one tariff can be the trial tariff at a time).
"""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
new_status = not tariff.is_trial_available
if new_status:
# При включении триала - снимаем флаг со ВСЕХ тарифов, затем ставим на текущий
# Это гарантирует, что триальным будет только один тариф
await db.execute(Tariff.__table__.update().values(is_trial_available=False))
await db.commit()
# Обновляем объект тарифа после массового обновления
await db.refresh(tariff)
await update_tariff(db, tariff, is_trial_available=new_status)
status_text = 'set as trial' if new_status else 'removed from trial'
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
return TariffTrialResponse(
id=tariff_id,
is_trial_available=new_status,
message=f'Tariff {status_text}',
)
@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse)
async def get_tariff_stats(
tariff_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get tariff statistics."""
tariff = await get_tariff_by_id(db, tariff_id)
if not tariff:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Tariff not found',
)
# Count subscriptions
total_result = await db.execute(select(func.count(Subscription.id)).where(Subscription.tariff_id == tariff_id))
total_count = total_result.scalar() or 0
# Count active subscriptions
active_result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.tariff_id == tariff_id,
Subscription.status == 'active',
)
)
active_count = active_result.scalar() or 0
# Count trial subscriptions
trial_result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.tariff_id == tariff_id,
Subscription.is_trial == True,
)
)
trial_count = trial_result.scalar() or 0
# Calculate revenue from subscription payments for users on this tariff
revenue_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.join(Subscription, Transaction.user_id == Subscription.user_id)
.where(
Subscription.tariff_id == tariff_id,
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.is_completed == True,
)
)
revenue_kopeks = revenue_result.scalar() or 0
return TariffStatsResponse(
id=tariff_id,
name=tariff.name,
subscriptions_count=total_count,
active_subscriptions=active_count,
trial_subscriptions=trial_count,
revenue_kopeks=revenue_kopeks,
revenue_rubles=revenue_kopeks / 100,
)
-607
View File
@@ -1,607 +0,0 @@
"""Admin tickets routes for cabinet."""
import logging
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.cabinet.routes.websocket import notify_user_ticket_reply
from app.config import settings
from app.database.crud.ticket import TicketCRUD
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import Ticket, TicketMessage, User
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.tickets import TicketMessageResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/tickets', tags=['Cabinet Admin Tickets'])
# Admin-specific schemas
class AdminTicketUserInfo(BaseModel):
"""User info for admin view."""
id: int
telegram_id: int | None = None # Can be None for email-only users
email: str | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
class Config:
from_attributes = True
class AdminTicketResponse(BaseModel):
"""Ticket data for admin."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: datetime | None = None
messages_count: int = 0
user: AdminTicketUserInfo | None = None
last_message: TicketMessageResponse | None = None
class Config:
from_attributes = True
class AdminTicketDetailResponse(BaseModel):
"""Ticket with all messages for admin."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: datetime | None = None
is_reply_blocked: bool = False
user: AdminTicketUserInfo | None = None
messages: list[TicketMessageResponse] = []
class Config:
from_attributes = True
class AdminTicketListResponse(BaseModel):
"""Paginated ticket list for admin."""
items: list[AdminTicketResponse]
total: int
page: int
per_page: int
pages: int
class AdminReplyRequest(BaseModel):
"""Admin reply to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description='Reply message')
class AdminStatusUpdateRequest(BaseModel):
"""Update ticket status."""
status: str = Field(..., description='New status: open, answered, pending, closed')
class AdminPriorityUpdateRequest(BaseModel):
"""Update ticket priority."""
priority: str = Field(..., description='New priority: low, normal, high, urgent')
class AdminStatsResponse(BaseModel):
"""Ticket statistics for admin."""
total: int
open: int
pending: int
answered: int
closed: int
class TicketSettingsResponse(BaseModel):
"""Ticket system settings."""
sla_enabled: bool
sla_minutes: int
sla_check_interval_seconds: int
sla_reminder_cooldown_minutes: int
support_system_mode: str # tickets, contact, both
# Cabinet notifications settings
cabinet_user_notifications_enabled: bool = True
cabinet_admin_notifications_enabled: bool = True
class TicketSettingsUpdateRequest(BaseModel):
"""Update ticket settings."""
sla_enabled: bool | None = None
sla_minutes: int | None = Field(None, ge=1, le=1440, description='SLA time in minutes (1-1440)')
sla_check_interval_seconds: int | None = Field(None, ge=30, le=600, description='Check interval (30-600 seconds)')
sla_reminder_cooldown_minutes: int | None = Field(
None, ge=1, le=120, description='Reminder cooldown (1-120 minutes)'
)
support_system_mode: str | None = Field(None, description='Support mode: tickets, contact, both')
# Cabinet notifications settings
cabinet_user_notifications_enabled: bool | None = Field(None, description='Enable user notifications in cabinet')
cabinet_admin_notifications_enabled: bool | None = Field(None, description='Enable admin notifications in cabinet')
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or '',
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
created_at=message.created_at,
)
def _user_to_info(user: User) -> AdminTicketUserInfo:
"""Convert User to admin info."""
return AdminTicketUserInfo(
id=user.id,
telegram_id=user.telegram_id,
email=user.email,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
)
def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) -> AdminTicketResponse:
"""Convert Ticket to admin response."""
last_message = None
messages_count = len(ticket.messages) if ticket.messages else 0
if ticket.messages:
last_msg = max(ticket.messages, key=lambda m: m.created_at)
last_message = _message_to_response(last_msg)
user_info = None
if hasattr(ticket, 'user') and ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
messages_count=messages_count,
user=user_info,
last_message=last_message,
)
@router.get('/stats', response_model=AdminStatsResponse)
async def get_ticket_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket statistics."""
# Total count
total_result = await db.execute(select(func.count()).select_from(Ticket))
total = total_result.scalar() or 0
# Count by status
statuses = {}
for status_name in ['open', 'pending', 'answered', 'closed']:
result = await db.execute(select(func.count()).select_from(Ticket).where(Ticket.status == status_name))
statuses[status_name] = result.scalar() or 0
return AdminStatsResponse(
total=total,
open=statuses.get('open', 0),
pending=statuses.get('pending', 0),
answered=statuses.get('answered', 0),
closed=statuses.get('closed', 0),
)
@router.get('/settings', response_model=TicketSettingsResponse)
async def get_ticket_settings(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
from app.services.support_settings_service import SupportSettingsService
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@router.patch('/settings', response_model=TicketSettingsResponse)
async def update_ticket_settings(
request: TicketSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
from pathlib import Path
from app.services.support_settings_service import SupportSettingsService
# Validate support_system_mode
if request.support_system_mode is not None:
mode = request.support_system_mode.strip().lower()
if mode not in {'tickets', 'contact', 'both'}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
)
# Update in-memory settings
if request.sla_enabled is not None:
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
if request.sla_minutes is not None:
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
if request.sla_check_interval_seconds is not None:
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
if request.sla_reminder_cooldown_minutes is not None:
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
if request.support_system_mode is not None:
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
# Update cabinet notification settings
if request.cabinet_user_notifications_enabled is not None:
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
if request.cabinet_admin_notifications_enabled is not None:
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
# Try to persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
updates = {}
if request.sla_enabled is not None:
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
if request.sla_minutes is not None:
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
if request.sla_check_interval_seconds is not None:
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
if request.sla_reminder_cooldown_minutes is not None:
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
if request.support_system_mode is not None:
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
new_lines = []
updated_keys = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f'{key}='):
new_lines.append(f'{key}={value}')
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
# Add any keys that weren't found
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file')
except Exception as e:
logger.warning(f'Failed to update .env file: {e}')
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@router.get('', response_model=AdminTicketListResponse)
async def get_all_tickets(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
user_id: int | None = Query(None, description='Filter by user ID'),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all tickets for admin."""
# Base query with user relationship
query = select(Ticket).options(selectinload(Ticket.messages), selectinload(Ticket.user))
# Build count query
count_query = select(func.count()).select_from(Ticket)
# Apply filters
if status_filter:
query = query.where(Ticket.status == status_filter)
count_query = count_query.where(Ticket.status == status_filter)
if priority_filter:
query = query.where(Ticket.priority == priority_filter)
count_query = count_query.where(Ticket.priority == priority_filter)
if user_id:
query = query.where(Ticket.user_id == user_id)
count_query = count_query.where(Ticket.user_id == user_id)
# Get total count
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate - order by updated_at desc (newest first)
offset = (page - 1) * per_page
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page)
result = await db.execute(query)
tickets = result.scalars().all()
items = [_ticket_to_admin_response(t) for t in tickets]
pages = math.ceil(total / per_page) if total > 0 else 1
return AdminTicketListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse)
async def get_ticket_detail(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket with all messages for admin."""
query = (
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
user=user_info,
messages=messages_response,
)
@router.post('/{ticket_id}/reply', response_model=TicketMessageResponse)
async def reply_to_ticket(
ticket_id: int,
request: AdminReplyRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reply to a ticket as admin."""
# Get ticket
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False, load_user=True)
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
# Create admin message
message = TicketMessage(
ticket_id=ticket.id,
user_id=ticket.user_id,
message_text=request.message,
is_from_admin=True,
created_at=datetime.utcnow(),
)
db.add(message)
# Update ticket status to answered
ticket.status = 'answered'
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
# Try to notify user via Telegram
try:
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
from app.handlers.admin.tickets import notify_user_about_ticket_reply
await notify_user_about_ticket_reply(bot, ticket, request.message, db)
except Exception as e:
logger.warning(f'Failed to notify user about ticket reply: {e}')
finally:
await bot.session.close()
except Exception as e:
logger.warning(f'Failed to send Telegram notification: {e}')
# Уведомить пользователя в кабинете
try:
notification = await TicketNotificationCRUD.create_user_notification_for_admin_reply(
db, ticket, request.message
)
if notification:
# Отправить WebSocket уведомление
await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or '')[:100])
except Exception as e:
logger.warning(f'Failed to create cabinet notification for admin reply: {e}')
return _message_to_response(message)
@router.post('/{ticket_id}/status', response_model=AdminTicketDetailResponse)
async def update_ticket_status(
ticket_id: int,
request: AdminStatusUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket status."""
allowed_statuses = {'open', 'pending', 'answered', 'closed'}
if request.status not in allowed_statuses:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid status. Allowed: {", ".join(allowed_statuses)}',
)
query = (
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
ticket.status = request.status
ticket.updated_at = datetime.utcnow()
if request.status == 'closed':
ticket.closed_at = datetime.utcnow()
else:
ticket.closed_at = None
await db.commit()
await db.refresh(ticket)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
user=user_info,
messages=messages_response,
)
@router.post('/{ticket_id}/priority', response_model=AdminTicketDetailResponse)
async def update_ticket_priority(
ticket_id: int,
request: AdminPriorityUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket priority."""
allowed_priorities = {'low', 'normal', 'high', 'urgent'}
if request.priority not in allowed_priorities:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid priority. Allowed: {", ".join(allowed_priorities)}',
)
query = (
select(Ticket).where(Ticket.id == ticket_id).options(selectinload(Ticket.messages), selectinload(Ticket.user))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
ticket.priority = request.priority
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(ticket)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
user_info = None
if ticket.user:
user_info = _user_to_info(ticket.user)
return AdminTicketDetailResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
user=user_info,
messages=messages_response,
)
-694
View File
@@ -1,694 +0,0 @@
"""Admin routes for traffic usage statistics."""
import asyncio
import csv
import io
import logging
import time
from datetime import UTC, datetime, timedelta
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import Subscription, Transaction, TransactionType, User
from app.services.remnawave_service import RemnaWaveService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.traffic import (
ExportCsvRequest,
ExportCsvResponse,
TrafficEnrichmentResponse,
TrafficNodeInfo,
TrafficUsageResponse,
UserTrafficEnrichment,
UserTrafficItem,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
_ALLOWED_PERIODS = frozenset({1, 3, 7, 14, 30})
_CONCURRENCY_LIMIT = 5 # Max parallel API calls to avoid rate limiting
# In-memory cache: {(start_str, end_str): (timestamp, aggregated_data, nodes_info)}
_traffic_cache: dict[tuple[str, str], tuple[float, dict[str, dict[str, int]], list[TrafficNodeInfo]]] = {}
_CACHE_TTL = 300 # 5 minutes
_cache_lock = asyncio.Lock()
# Valid sort fields for the GET endpoint
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
_ENRICHMENT_SORT_FIELDS = frozenset({'connected', 'total_spent', 'sub_start', 'sub_end', 'last_node'})
def _get_status(sub) -> str | None:
"""Get subscription status via actual_status property."""
return sub.actual_status
def _validate_period(period: int) -> None:
if period not in _ALLOWED_PERIODS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Period must be one of: {sorted(_ALLOWED_PERIODS)}',
)
async def _aggregate_traffic(
start_str: str, end_str: str, user_uuids: list[str]
) -> tuple[dict[str, dict[str, int]], list[TrafficNodeInfo]]:
"""Aggregate per-user traffic across all nodes for a given date range.
Uses legacy per-node endpoint to fetch all users' traffic per node —
O(nodes) API calls instead of O(users). The legacy endpoint returns
{userUuid, nodeUuid, total} per entry (non-legacy only returns topUsers
without userUuid).
Returns (user_traffic, nodes_info) where:
user_traffic = {remnawave_uuid: {node_uuid: total_bytes, ...}}
nodes_info = [TrafficNodeInfo, ...]
"""
cache_key = (start_str, end_str)
# Quick check without lock
now = time.time()
cached = _traffic_cache.get(cache_key)
if cached and (now - cached[0]) < _CACHE_TTL:
return cached[1], cached[2]
# Acquire lock for the slow path
async with _cache_lock:
# Re-check after acquiring lock
now = time.time()
cached = _traffic_cache.get(cache_key)
if cached and (now - cached[0]) < _CACHE_TTL:
return cached[1], cached[2]
service = RemnaWaveService()
if not service.is_configured:
return {}, []
user_uuids_set = set(user_uuids)
async with service.get_api_client() as api:
nodes = await api.get_all_nodes()
# Fetch per-node user stats — O(nodes) calls instead of O(users)
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
async def fetch_node_users(node):
async with semaphore:
try:
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
return node.uuid, stats
except Exception:
logger.warning('Failed to get traffic for node %s', node.name, exc_info=True)
return node.uuid, None
results = await asyncio.gather(*(fetch_node_users(n) for n in nodes))
nodes_info: list[TrafficNodeInfo] = [
TrafficNodeInfo(node_uuid=node.uuid, node_name=node.name, country_code=node.country_code) for node in nodes
]
nodes_info.sort(key=lambda n: n.node_name)
# Legacy response: [{userUuid, username, nodeUuid, total, date}, ...]
user_traffic: dict[str, dict[str, int]] = {}
for node_uuid, entries in results:
if not isinstance(entries, list):
continue
for entry in entries:
uid = entry.get('userUuid', '')
total = int(entry.get('total', 0))
if uid and total > 0 and uid in user_uuids_set:
user_traffic.setdefault(uid, {})[node_uuid] = user_traffic.get(uid, {}).get(node_uuid, 0) + total
_traffic_cache[cache_key] = (now, user_traffic, nodes_info)
# Evict expired entries to prevent unbounded growth
expired = [k for k, (ts, _, _) in _traffic_cache.items() if (now - ts) >= _CACHE_TTL]
for k in expired:
del _traffic_cache[k]
return user_traffic, nodes_info
def _compute_date_range(period_days: int) -> tuple[str, str]:
"""Compute ISO date-time range from period days.
Truncates to 5-minute intervals for stable cache keys.
"""
end_dt = datetime.now(UTC).replace(second=0, microsecond=0)
end_dt = end_dt.replace(minute=(end_dt.minute // 5) * 5)
start_dt = end_dt - timedelta(days=period_days)
return start_dt.strftime('%Y-%m-%dT%H:%M:%SZ'), end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
stmt = (
select(User)
.where(User.remnawave_uuid.isnot(None))
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
)
result = await db.execute(stmt)
users = result.scalars().all()
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
def _build_traffic_items(
user_traffic: dict[str, dict[str, int]],
user_map: dict[str, User],
nodes_info: list[TrafficNodeInfo],
search: str = '',
sort_by: str = 'total_bytes',
sort_desc: bool = True,
tariff_filter: set[str] | None = None,
status_filter: set[str] | None = None,
node_filter: set[str] | None = None,
) -> list[UserTrafficItem]:
"""Merge traffic data with user data, apply search/tariff/status/node filters, return sorted list."""
items: list[UserTrafficItem] = []
search_lower = search.lower().strip()
all_uuids = set(user_traffic.keys()) | set(user_map.keys())
for uuid in all_uuids:
user = user_map.get(uuid)
if not user:
continue
traffic = user_traffic.get(uuid, {})
full_name = user.full_name
username = user.username
email = user.email
if search_lower:
if (
search_lower not in (full_name or '').lower()
and search_lower not in (username or '').lower()
and search_lower not in (email or '').lower()
):
continue
sub = user.subscription
tariff_name = None
subscription_status = None
traffic_limit_gb = 0.0
device_limit = 1
if sub:
subscription_status = _get_status(sub)
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
device_limit = sub.device_limit or 1
if sub.tariff:
tariff_name = sub.tariff.name
if tariff_filter is not None:
if (tariff_name or '') not in tariff_filter:
continue
if status_filter is not None:
if (subscription_status or '') not in status_filter:
continue
# Apply node filter: keep only selected nodes, recalculate total
if node_filter is not None:
traffic = {k: v for k, v in traffic.items() if k in node_filter}
total_bytes = sum(traffic.values())
items.append(
UserTrafficItem(
user_id=user.id,
telegram_id=user.telegram_id,
username=username,
email=email,
full_name=full_name,
tariff_name=tariff_name,
subscription_status=subscription_status,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
node_traffic=traffic,
total_bytes=total_bytes,
)
)
# Sort by the requested field; node columns use 'node_<uuid>' prefix
if sort_by.startswith('node_'):
node_uuid = sort_by[5:]
items.sort(key=lambda x: x.node_traffic.get(node_uuid, 0), reverse=sort_desc)
elif sort_by in ('full_name', 'tariff_name'):
items.sort(key=lambda x: (getattr(x, sort_by, None) or '').lower(), reverse=sort_desc)
else:
items.sort(key=lambda x: getattr(x, sort_by, 0) or 0, reverse=sort_desc)
return items
@router.get('', response_model=TrafficUsageResponse)
async def get_traffic_usage(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
period: int = Query(30, ge=1, le=30),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
search: str = Query('', max_length=100),
sort_by: str = Query('total_bytes', max_length=100),
sort_desc: bool = Query(True),
tariffs: str = Query('', max_length=500),
statuses: str = Query('', max_length=500),
nodes: str = Query('', max_length=2000),
start_date: str = Query('', max_length=10),
end_date: str = Query('', max_length=10),
):
"""Get paginated per-user traffic usage by node."""
# Determine date range: custom dates or period-based
if start_date.strip() and end_date.strip():
try:
start_dt = datetime.strptime(start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
end_dt = datetime.strptime(end_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC, hour=23, minute=59, second=59)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
now = datetime.now(UTC)
end_dt = min(end_dt, now)
if start_dt > end_dt:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
if (end_dt - start_dt).days > 31:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
effective_period = (end_dt - start_dt).days or 1
else:
_validate_period(period)
start_str, end_str = _compute_date_range(period)
effective_period = period
user_map = await _load_user_map(db)
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
# Collect all available tariff names (before filtering)
available_tariffs = sorted(
{
u.subscription.tariff.name
for u in user_map.values()
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
}
)
# Collect all available statuses (before filtering)
available_statuses = sorted(
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
)
# Parse tariff filter
tariff_filter: set[str] | None = None
if tariffs.strip():
tariff_filter = {t.strip() for t in tariffs.split(',') if t.strip()}
# Parse status filter
status_filter: set[str] | None = None
if statuses.strip():
status_filter = {s.strip() for s in statuses.split(',') if s.strip()}
# Parse node filter
node_filter: set[str] | None = None
all_node_uuids = {n.node_uuid for n in nodes_info}
if nodes.strip():
node_filter = {n.strip() for n in nodes.split(',') if n.strip()} & all_node_uuids
if not node_filter:
node_filter = None # No valid nodes matched, treat as "all nodes"
# Validate sort_by: allow known fields + enrichment fields + 'node_<uuid>'
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
is_enrichment_sort = sort_by in _ENRICHMENT_SORT_FIELDS
if sort_by not in _SORT_FIELDS and not is_node_sort and not is_enrichment_sort:
sort_by = 'total_bytes'
# For enrichment sort, build items unsorted then sort by enrichment field
effective_sort = 'total_bytes' if is_enrichment_sort else sort_by
items = _build_traffic_items(
user_traffic, user_map, nodes_info, search, effective_sort, sort_desc, tariff_filter, status_filter, node_filter
)
if is_enrichment_sort:
enrichment_data = await _build_enrichment(db, user_map)
enr_key_map = {
'connected': lambda e: e.devices_connected,
'total_spent': lambda e: e.total_spent_kopeks,
'sub_start': lambda e: e.subscription_start_date or '',
'sub_end': lambda e: e.subscription_end_date or '',
'last_node': lambda e: e.last_node_name or '',
}
key_fn = enr_key_map[sort_by]
empty = UserTrafficEnrichment()
items.sort(key=lambda x: key_fn(enrichment_data.get(x.user_id, empty)), reverse=sort_desc)
total = len(items)
paginated = items[offset : offset + limit]
return TrafficUsageResponse(
items=paginated,
nodes=nodes_info,
total=total,
offset=offset,
limit=limit,
period_days=effective_period,
available_tariffs=available_tariffs,
available_statuses=available_statuses,
)
# ============== Enrichment endpoint ==============
_enrichment_cache: dict[str, tuple[float, dict[int, UserTrafficEnrichment]]] = {}
_ENRICHMENT_CACHE_TTL = 300 # 5 minutes
_enrichment_lock = asyncio.Lock()
async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int, int]:
"""Get total spent kopeks for multiple users in a single query."""
if not user_ids:
return {}
result = await db.execute(
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id.in_(user_ids),
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
)
)
.group_by(Transaction.user_id)
)
return {row[0]: int(row[1]) for row in result.all()}
async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict[int, UserTrafficEnrichment]:
"""Build enrichment data for all users: devices, spending, dates, last node."""
uuid_to_user_id: dict[str, int] = {}
for uuid, user in user_map.items():
uuid_to_user_id[uuid] = user.id
service = RemnaWaveService()
devices_by_user: dict[int, int] = {}
last_node_uuid_by_user: dict[int, str] = {}
node_uuid_to_name: dict[str, str] = {}
if service.is_configured:
async with service.get_api_client() as api:
# 3 bulk calls: nodes + users (paginated) + devices
try:
nodes_list = await api.get_all_nodes()
except Exception:
logger.warning('Failed to fetch nodes for enrichment', exc_info=True)
nodes_list = []
for node in nodes_list:
node_uuid_to_name[node.uuid] = node.name
# Fetch all panel users (paginated) for last connected node
panel_users = []
try:
first_page = await api.get_all_users(start=0, size=500)
panel_users.extend(first_page['users'])
total_panel = first_page['total']
if total_panel > 500:
remaining_tasks = [
api.get_all_users(start=offset, size=500) for offset in range(500, total_panel, 500)
]
pages = await asyncio.gather(*remaining_tasks, return_exceptions=True)
for page in pages:
if isinstance(page, dict):
panel_users.extend(page['users'])
except Exception:
logger.warning('Failed to fetch panel users for enrichment', exc_info=True)
for pu in panel_users:
uid = uuid_to_user_id.get(pu.uuid)
if uid is None:
continue
if pu.user_traffic and pu.user_traffic.last_connected_node_uuid:
last_node_uuid_by_user[uid] = pu.user_traffic.last_connected_node_uuid
# Bulk device fetch — single API call (paginated with start/size)
try:
devices_data = await api.get_all_hwid_devices()
for device in devices_data.get('devices', []):
user_uuid = device.get('userUuid', '')
uid = uuid_to_user_id.get(user_uuid)
if uid is not None:
devices_by_user[uid] = devices_by_user.get(uid, 0) + 1
except Exception:
logger.warning('Failed to fetch bulk devices for enrichment', exc_info=True)
# Bulk spending stats
all_user_ids = [u.id for u in user_map.values()]
spending_map = await _get_bulk_spending(db, all_user_ids)
# Build enrichment data
enrichment: dict[int, UserTrafficEnrichment] = {}
for uuid, user in user_map.items():
uid = user.id
sub = user.subscription
start_date = None
end_date = None
if sub:
if sub.start_date:
start_date = sub.start_date.isoformat()
if sub.end_date:
end_date = sub.end_date.isoformat()
last_node_name = None
last_uuid = last_node_uuid_by_user.get(uid)
if last_uuid:
last_node_name = node_uuid_to_name.get(last_uuid)
enrichment[uid] = UserTrafficEnrichment(
devices_connected=devices_by_user.get(uid, 0),
total_spent_kopeks=spending_map.get(uid, 0),
subscription_start_date=start_date,
subscription_end_date=end_date,
last_node_name=last_node_name,
)
return enrichment
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
async def get_traffic_enrichment(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return enrichment data: device counts, spending, dates, last node."""
cache_key = 'enrichment'
now = time.time()
cached = _enrichment_cache.get(cache_key)
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
return TrafficEnrichmentResponse(data=cached[1])
async with _enrichment_lock:
now = time.time()
cached = _enrichment_cache.get(cache_key)
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
return TrafficEnrichmentResponse(data=cached[1])
user_map = await _load_user_map(db)
enrichment = await _build_enrichment(db, user_map)
_enrichment_cache[cache_key] = (now, enrichment)
# Evict expired
expired = [k for k, (ts, _) in _enrichment_cache.items() if (now - ts) >= _ENRICHMENT_CACHE_TTL]
for k in expired:
del _enrichment_cache[k]
return TrafficEnrichmentResponse(data=enrichment)
@router.post('/export-csv', response_model=ExportCsvResponse)
async def export_traffic_csv(
request: ExportCsvRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
if not admin.telegram_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Admin has no Telegram ID configured',
)
# Determine date range: custom dates or period-based
if request.start_date and request.end_date:
try:
start_dt = datetime.strptime(request.start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
end_dt = datetime.strptime(request.end_date.strip(), '%Y-%m-%d').replace(
tzinfo=UTC, hour=23, minute=59, second=59
)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
now = datetime.now(UTC)
end_dt = min(end_dt, now)
if start_dt > end_dt:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
if (end_dt - start_dt).days > 31:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
period_label = f'{request.start_date}_{request.end_date}'
else:
_validate_period(request.period)
start_str, end_str = _compute_date_range(request.period)
period_label = f'{request.period}d'
user_map = await _load_user_map(db)
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
enrichment = await _build_enrichment(db, user_map)
# Parse filters
tariff_filter: set[str] | None = None
if request.tariffs and request.tariffs.strip():
tariff_filter = {t.strip() for t in request.tariffs.split(',') if t.strip()}
status_filter: set[str] | None = None
if request.statuses and request.statuses.strip():
status_filter = {s.strip() for s in request.statuses.split(',') if s.strip()}
node_filter: set[str] | None = None
all_node_uuids = {n.node_uuid for n in nodes_info}
if request.nodes and request.nodes.strip():
node_filter = {n.strip() for n in request.nodes.split(',') if n.strip()} & all_node_uuids
if not node_filter:
node_filter = None
items = _build_traffic_items(
user_traffic,
user_map,
nodes_info,
sort_by='total_bytes',
sort_desc=True,
tariff_filter=tariff_filter,
status_filter=status_filter,
node_filter=node_filter,
)
# Determine which nodes to include in CSV columns
csv_nodes = [n for n in nodes_info if n.node_uuid in node_filter] if node_filter else nodes_info
# Compute period days for risk calculation
if request.start_date and request.end_date:
period_days = max((end_dt - start_dt).days, 1)
else:
period_days = request.period
total_thr = request.total_threshold_gb or 0
node_thr = request.node_threshold_gb or 0
has_risk = total_thr > 0 or node_thr > 0
# Build CSV rows
rows: list[dict] = []
for item in items:
row: dict = {
'User ID': item.user_id,
'Telegram ID': item.telegram_id or '',
'Username': item.username or '',
'Email': item.email or '',
'Full Name': item.full_name,
'Tariff': item.tariff_name or '',
'Status': item.subscription_status or '',
'Traffic Limit (GB)': item.traffic_limit_gb,
'Device Limit': item.device_limit,
}
# Enrichment columns
enr = enrichment.get(item.user_id)
row['Connected Devices'] = enr.devices_connected if enr else 0
row['Total Spent (RUB)'] = round(enr.total_spent_kopeks / 100, 2) if enr else 0
row['Sub Start'] = enr.subscription_start_date or '' if enr else ''
row['Sub End'] = enr.subscription_end_date or '' if enr else ''
row['Last Node'] = enr.last_node_name or '' if enr else ''
for node in csv_nodes:
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
row['Total (bytes)'] = item.total_bytes
row['Total (GB)'] = round(item.total_bytes / (1024**3), 2) if item.total_bytes else 0
if has_risk:
daily_total = item.total_bytes / period_days / (1024**3) if period_days > 0 else 0
row['Total GB/day'] = round(daily_total, 4)
total_ratio = daily_total / total_thr if total_thr > 0 else 0
max_node_ratio = 0.0
worst_node_daily = 0.0
for node_bytes in item.node_traffic.values():
if node_bytes > 0 and node_thr > 0:
daily_node = node_bytes / period_days / (1024**3) if period_days > 0 else 0
ratio = daily_node / node_thr
if ratio > max_node_ratio:
max_node_ratio = ratio
worst_node_daily = daily_node
ratio = max(total_ratio, max_node_ratio)
if ratio < 0.5:
risk_level = 'low'
elif ratio < 0.8:
risk_level = 'medium'
elif ratio < 1.2:
risk_level = 'high'
else:
risk_level = 'critical'
row['Risk Level'] = risk_level
row['Risk Ratio'] = round(ratio, 3)
row['Risk GB/day'] = round(daily_total if total_ratio >= max_node_ratio else worst_node_daily, 4)
rows.append(row)
# Generate CSV
output = io.StringIO()
if rows:
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
csv_bytes = output.getvalue().encode('utf-8-sig')
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
try:
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
async with bot:
await bot.send_document(
chat_id=admin.telegram_id,
document=BufferedInputFile(csv_bytes, filename=filename),
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
)
except Exception:
logger.error('Failed to send CSV to admin %s', admin.telegram_id, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to send CSV report. Please try again later.',
)
return ExportCsvResponse(success=True, message=f'CSV sent ({len(rows)} users)')
-139
View File
@@ -1,139 +0,0 @@
"""Admin routes for version and release information."""
import logging
from datetime import datetime, timedelta
import aiohttp
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from app.database.models import User
from app.services.version_service import version_service
from ..dependencies import get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
# ============ Schemas ============
class ReleaseItem(BaseModel):
tag_name: str
name: str
body: str
published_at: str
prerelease: bool
class ProjectReleasesInfo(BaseModel):
current_version: str
has_updates: bool
releases: list[ReleaseItem]
repo_url: str
class ReleasesResponse(BaseModel):
bot: ProjectReleasesInfo
cabinet: ProjectReleasesInfo
# ============ Cabinet releases cache ============
CABINET_REPO = 'BEDOLAGA-DEV/bedolaga-cabinet'
_cabinet_cache: dict = {}
_cabinet_last_check: datetime | None = None
_CACHE_TTL = 3600
async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
global _cabinet_last_check
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
return _cabinet_cache['releases']
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
if response.status == 200:
data = await response.json()
releases = []
for item in data[:20]:
releases.append(
{
'tag_name': item['tag_name'],
'name': item.get('name') or item['tag_name'],
'body': item.get('body') or '',
'published_at': item['published_at'],
'prerelease': item.get('prerelease', False),
}
)
_cabinet_cache['releases'] = releases
_cabinet_last_check = datetime.now()
logger.info('Fetched %d cabinet releases from GitHub', len(releases))
return releases
logger.warning('GitHub API returned status %d for cabinet releases', response.status)
return _cabinet_cache.get('releases', [])
except TimeoutError:
logger.warning('Timeout fetching cabinet releases from GitHub')
return _cabinet_cache.get('releases', [])
except Exception as e:
logger.error('Error fetching cabinet releases: %s', e)
return _cabinet_cache.get('releases', [])
# ============ Routes ============
@router.get('/releases', response_model=ReleasesResponse)
async def get_releases(
current_user: User = Depends(get_current_admin_user),
) -> ReleasesResponse:
"""Get release information for bot and cabinet."""
# Bot releases
bot_releases_raw = await version_service._fetch_releases()
has_updates, _ = await version_service.check_for_updates()
bot_releases = [
ReleaseItem(
tag_name=r.tag_name,
name=r.name,
body=r.full_description,
published_at=r.published_at.isoformat(),
prerelease=r.prerelease,
)
for r in bot_releases_raw[:10]
]
bot_info = ProjectReleasesInfo(
current_version=version_service.current_version,
has_updates=has_updates,
releases=bot_releases,
repo_url=f'https://github.com/{version_service.repo}',
)
# Cabinet releases
cabinet_releases_raw = await _fetch_cabinet_releases()
cabinet_releases = [ReleaseItem(**r) for r in cabinet_releases_raw[:10]]
# Current version = latest non-prerelease tag
cabinet_current = ''
for r in cabinet_releases_raw:
if not r.get('prerelease', False):
cabinet_current = r['tag_name']
break
cabinet_info = ProjectReleasesInfo(
current_version=cabinet_current,
has_updates=False,
releases=cabinet_releases,
repo_url=f'https://github.com/{CABINET_REPO}',
)
return ReleasesResponse(bot=bot_info, cabinet=cabinet_info)
File diff suppressed because it is too large Load Diff
-387
View File
@@ -1,387 +0,0 @@
"""
API роуты колеса удачи для администраторов.
"""
import logging
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
from app.cabinet.schemas.wheel import (
AdminSpinItem,
AdminSpinsResponse,
AdminWheelConfigResponse,
CreatePrizeRequest,
ReorderPrizesRequest,
UpdatePrizeRequest,
UpdateWheelConfigRequest,
WheelPrizeAdminResponse,
WheelStatisticsResponse,
)
from app.database.crud.wheel import (
create_wheel_prize,
delete_wheel_prize,
get_all_spins,
get_or_create_wheel_config,
get_wheel_prizes,
reorder_wheel_prizes,
update_wheel_config,
update_wheel_prize,
)
from app.database.models import User
from app.services.wheel_service import wheel_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel'])
@router.get('/config', response_model=AdminWheelConfigResponse)
async def get_admin_wheel_config(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить полную конфигурацию колеса."""
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=False)
prizes_response = [
WheelPrizeAdminResponse(
id=p.id,
config_id=p.config_id,
prize_type=p.prize_type,
prize_value=p.prize_value,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_value_kopeks=p.prize_value_kopeks,
sort_order=p.sort_order,
manual_probability=p.manual_probability,
is_active=p.is_active,
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
promo_subscription_days=p.promo_subscription_days or 0,
promo_traffic_gb=p.promo_traffic_gb or 0,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in prizes
]
return AdminWheelConfigResponse(
id=config.id,
is_enabled=config.is_enabled,
name=config.name,
spin_cost_stars=config.spin_cost_stars,
spin_cost_days=config.spin_cost_days,
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
spin_cost_days_enabled=config.spin_cost_days_enabled,
rtp_percent=config.rtp_percent,
daily_spin_limit=config.daily_spin_limit,
min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment,
promo_prefix=config.promo_prefix,
promo_validity_days=config.promo_validity_days,
prizes=prizes_response,
created_at=config.created_at,
updated_at=config.updated_at,
)
@router.put('/config', response_model=AdminWheelConfigResponse)
async def update_admin_wheel_config(
request: UpdateWheelConfigRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить конфигурацию колеса."""
update_data = request.model_dump(exclude_unset=True)
if not update_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No fields to update',
)
config = await update_wheel_config(db, **update_data)
logger.info(f'🎡 Admin {admin.telegram_id} updated wheel config: {update_data}')
# Возвращаем полную конфигурацию
prizes = await get_wheel_prizes(db, config.id, active_only=False)
prizes_response = [
WheelPrizeAdminResponse(
id=p.id,
config_id=p.config_id,
prize_type=p.prize_type,
prize_value=p.prize_value,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_value_kopeks=p.prize_value_kopeks,
sort_order=p.sort_order,
manual_probability=p.manual_probability,
is_active=p.is_active,
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
promo_subscription_days=p.promo_subscription_days or 0,
promo_traffic_gb=p.promo_traffic_gb or 0,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in prizes
]
return AdminWheelConfigResponse(
id=config.id,
is_enabled=config.is_enabled,
name=config.name,
spin_cost_stars=config.spin_cost_stars,
spin_cost_days=config.spin_cost_days,
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
spin_cost_days_enabled=config.spin_cost_days_enabled,
rtp_percent=config.rtp_percent,
daily_spin_limit=config.daily_spin_limit,
min_subscription_days_for_day_payment=config.min_subscription_days_for_day_payment,
promo_prefix=config.promo_prefix,
promo_validity_days=config.promo_validity_days,
prizes=prizes_response,
created_at=config.created_at,
updated_at=config.updated_at,
)
@router.get('/prizes', response_model=list[WheelPrizeAdminResponse])
async def get_prizes(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить список призов."""
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=False)
return [
WheelPrizeAdminResponse(
id=p.id,
config_id=p.config_id,
prize_type=p.prize_type,
prize_value=p.prize_value,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_value_kopeks=p.prize_value_kopeks,
sort_order=p.sort_order,
manual_probability=p.manual_probability,
is_active=p.is_active,
promo_balance_bonus_kopeks=p.promo_balance_bonus_kopeks or 0,
promo_subscription_days=p.promo_subscription_days or 0,
promo_traffic_gb=p.promo_traffic_gb or 0,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in prizes
]
@router.post('/prizes', response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
async def create_prize(
request: CreatePrizeRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Создать новый приз."""
config = await get_or_create_wheel_config(db)
prize = await create_wheel_prize(
db=db,
config_id=config.id,
prize_type=request.prize_type.value,
prize_value=request.prize_value,
display_name=request.display_name,
prize_value_kopeks=request.prize_value_kopeks,
emoji=request.emoji,
color=request.color,
sort_order=request.sort_order,
manual_probability=request.manual_probability,
is_active=request.is_active,
promo_balance_bonus_kopeks=request.promo_balance_bonus_kopeks,
promo_subscription_days=request.promo_subscription_days,
promo_traffic_gb=request.promo_traffic_gb,
)
logger.info(f'🎁 Admin {admin.telegram_id} created prize: {prize.display_name}')
return WheelPrizeAdminResponse(
id=prize.id,
config_id=prize.config_id,
prize_type=prize.prize_type,
prize_value=prize.prize_value,
display_name=prize.display_name,
emoji=prize.emoji,
color=prize.color,
prize_value_kopeks=prize.prize_value_kopeks,
sort_order=prize.sort_order,
manual_probability=prize.manual_probability,
is_active=prize.is_active,
promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0,
promo_subscription_days=prize.promo_subscription_days or 0,
promo_traffic_gb=prize.promo_traffic_gb or 0,
created_at=prize.created_at,
updated_at=prize.updated_at,
)
@router.put('/prizes/{prize_id}', response_model=WheelPrizeAdminResponse)
async def update_prize(
prize_id: int,
request: UpdatePrizeRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Обновить приз."""
update_data = request.model_dump(exclude_unset=True)
# Конвертируем enum в строку если есть
if update_data.get('prize_type'):
update_data['prize_type'] = update_data['prize_type'].value
if not update_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No fields to update',
)
prize = await update_wheel_prize(db, prize_id, **update_data)
if not prize:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Prize not found',
)
logger.info(f'🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}')
return WheelPrizeAdminResponse(
id=prize.id,
config_id=prize.config_id,
prize_type=prize.prize_type,
prize_value=prize.prize_value,
display_name=prize.display_name,
emoji=prize.emoji,
color=prize.color,
prize_value_kopeks=prize.prize_value_kopeks,
sort_order=prize.sort_order,
manual_probability=prize.manual_probability,
is_active=prize.is_active,
promo_balance_bonus_kopeks=prize.promo_balance_bonus_kopeks or 0,
promo_subscription_days=prize.promo_subscription_days or 0,
promo_traffic_gb=prize.promo_traffic_gb or 0,
created_at=prize.created_at,
updated_at=prize.updated_at,
)
@router.delete('/prizes/{prize_id}', status_code=status.HTTP_204_NO_CONTENT)
async def delete_prize_endpoint(
prize_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Удалить приз."""
success = await delete_wheel_prize(db, prize_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Prize not found',
)
logger.info(f'🗑️ Admin {admin.telegram_id} deleted prize {prize_id}')
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
async def reorder_prizes(
request: ReorderPrizesRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Переупорядочить призы."""
await reorder_wheel_prizes(db, request.prize_ids)
logger.info(f'🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}')
return {'success': True}
@router.get('/statistics', response_model=WheelStatisticsResponse)
async def get_statistics(
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить статистику колеса."""
stats = await wheel_service.get_statistics(db, date_from, date_to)
return WheelStatisticsResponse(
total_spins=stats['total_spins'],
total_revenue_kopeks=stats['total_revenue_kopeks'],
total_payout_kopeks=stats['total_payout_kopeks'],
actual_rtp_percent=stats['actual_rtp_percent'],
configured_rtp_percent=stats['configured_rtp_percent'],
spins_by_payment_type=stats['spins_by_payment_type'],
prizes_distribution=stats['prizes_distribution'],
top_wins=stats['top_wins'],
period_from=stats['period_from'],
period_to=stats['period_to'],
)
@router.get('/spins', response_model=AdminSpinsResponse)
async def get_all_spins_endpoint(
user_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить все спины с фильтрами."""
offset = (page - 1) * per_page
spins, total = await get_all_spins(
db,
user_id=user_id,
date_from=date_from,
date_to=date_to,
limit=per_page,
offset=offset,
)
items = [
AdminSpinItem(
id=spin.id,
user_id=spin.user_id,
username=spin.user.username if spin.user else None,
payment_type=spin.payment_type,
payment_amount=spin.payment_amount,
payment_value_kopeks=spin.payment_value_kopeks,
prize_type=spin.prize_type,
prize_value=spin.prize_value,
prize_display_name=spin.prize_display_name,
prize_value_kopeks=spin.prize_value_kopeks,
is_applied=spin.is_applied,
created_at=spin.created_at,
)
for spin in spins
]
pages = math.ceil(total / per_page) if total > 0 else 1
return AdminSpinsResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-769
View File
@@ -1,769 +0,0 @@
"""Branding routes for cabinet - logo, project name, and theme colors management."""
import json
import logging
import os
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import SystemSetting, User
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/branding', tags=['Branding'])
# Directory for storing branding assets
BRANDING_DIR = Path('data/branding')
LOGO_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.svg']
# Settings keys
BRANDING_NAME_KEY = 'CABINET_BRANDING_NAME'
BRANDING_LOGO_KEY = 'CABINET_BRANDING_LOGO' # Stores "custom" or "default"
THEME_COLORS_KEY = 'CABINET_THEME_COLORS' # Stores JSON with theme colors
ENABLED_THEMES_KEY = 'CABINET_ENABLED_THEMES' # Stores JSON with enabled themes {"dark": true, "light": false}
ANIMATION_ENABLED_KEY = 'CABINET_ANIMATION_ENABLED' # Stores "true" or "false"
FULLSCREEN_ENABLED_KEY = 'CABINET_FULLSCREEN_ENABLED' # Stores "true" or "false"
EMAIL_AUTH_ENABLED_KEY = 'CABINET_EMAIL_AUTH_ENABLED' # Stores "true" or "false"
YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeric string)
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
# Allowed image types
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB for larger logos
# ============ Schemas ============
class BrandingResponse(BaseModel):
"""Current branding settings."""
name: str
logo_url: str | None = None
logo_letter: str
has_custom_logo: bool
class BrandingNameUpdate(BaseModel):
"""Request to update branding name."""
name: str
class ThemeColorsResponse(BaseModel):
"""Theme colors settings."""
accent: str = '#3b82f6'
darkBackground: str = '#0a0f1a'
darkSurface: str = '#0f172a'
darkText: str = '#f1f5f9'
darkTextSecondary: str = '#94a3b8'
lightBackground: str = '#F7E7CE'
lightSurface: str = '#FEF9F0'
lightText: str = '#1F1A12'
lightTextSecondary: str = '#7D6B48'
success: str = '#22c55e'
warning: str = '#f59e0b'
error: str = '#ef4444'
class ThemeColorsUpdate(BaseModel):
"""Request to update theme colors (partial update allowed)."""
accent: str | None = None
darkBackground: str | None = None
darkSurface: str | None = None
darkText: str | None = None
darkTextSecondary: str | None = None
lightBackground: str | None = None
lightSurface: str | None = None
lightText: str | None = None
lightTextSecondary: str | None = None
success: str | None = None
warning: str | None = None
error: str | None = None
class EnabledThemesResponse(BaseModel):
"""Enabled themes settings."""
dark: bool = True
light: bool = True
class EnabledThemesUpdate(BaseModel):
"""Request to update enabled themes."""
dark: bool | None = None
light: bool | None = None
class AnimationEnabledResponse(BaseModel):
"""Animation enabled setting."""
enabled: bool = True
class AnimationEnabledUpdate(BaseModel):
"""Request to update animation setting."""
enabled: bool
class FullscreenEnabledResponse(BaseModel):
"""Fullscreen enabled setting."""
enabled: bool = False
class FullscreenEnabledUpdate(BaseModel):
"""Request to update fullscreen setting."""
enabled: bool
class EmailAuthEnabledResponse(BaseModel):
"""Email auth enabled setting."""
enabled: bool = True
class EmailAuthEnabledUpdate(BaseModel):
"""Request to update email auth setting."""
enabled: bool
class LiteModeEnabledResponse(BaseModel):
"""Lite mode enabled setting."""
enabled: bool = False
class LiteModeEnabledUpdate(BaseModel):
"""Request to update lite mode setting."""
enabled: bool
class AnalyticsCountersResponse(BaseModel):
"""Analytics counter settings."""
yandex_metrika_id: str = ''
google_ads_id: str = ''
google_ads_label: str = ''
class AnalyticsCountersUpdate(BaseModel):
"""Request to update analytics counters (partial update allowed)."""
yandex_metrika_id: str | None = None
google_ads_id: str | None = None
google_ads_label: str | None = None
# Default theme colors
DEFAULT_THEME_COLORS = {
'accent': '#3b82f6',
'darkBackground': '#0a0f1a',
'darkSurface': '#0f172a',
'darkText': '#f1f5f9',
'darkTextSecondary': '#94a3b8',
'lightBackground': '#F7E7CE',
'lightSurface': '#FEF9F0',
'lightText': '#1F1A12',
'lightTextSecondary': '#7D6B48',
'success': '#22c55e',
'warning': '#f59e0b',
'error': '#ef4444',
}
# ============ Helper Functions ============
def ensure_branding_dir():
"""Ensure branding directory exists."""
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
async def get_setting_value(db: AsyncSession, key: str) -> str | None:
"""Get a setting value from database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def set_setting_value(db: AsyncSession, key: str, value: str):
"""Set a setting value in database."""
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
await db.commit()
def get_logo_path() -> Path | None:
"""Get the path to the custom logo file (any supported format)."""
if not BRANDING_DIR.exists():
return None
# Search for logo file with any supported extension
for ext in LOGO_EXTENSIONS:
logo_path = BRANDING_DIR / f'logo{ext}'
if logo_path.exists():
return logo_path
return None
def has_custom_logo() -> bool:
"""Check if a custom logo exists."""
return get_logo_path() is not None
# ============ Routes ============
@router.get('', response_model=BrandingResponse)
async def get_branding(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get current branding settings.
This is a public endpoint - no authentication required.
"""
# Get name from database or use default from env/settings
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
# Check for custom logo
custom_logo = has_custom_logo()
# Get first letter for logo fallback (use "V" if name is empty)
logo_letter = name[0].upper() if name else 'V'
return BrandingResponse(
name=name,
logo_url='/cabinet/branding/logo' if custom_logo else None,
logo_letter=logo_letter,
has_custom_logo=custom_logo,
)
@router.get('/logo')
async def get_logo():
"""
Get the custom logo image.
Returns 404 if no custom logo is set.
"""
logo_path = get_logo_path()
if logo_path is None or not logo_path.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='No custom logo set')
# Determine media type from file extension
suffix = logo_path.suffix.lower()
media_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
}
media_type = media_types.get(suffix, 'image/png')
return FileResponse(logo_path, media_type=media_type, headers={'Cache-Control': 'public, max-age=3600'})
@router.put('/name', response_model=BrandingResponse)
async def update_branding_name(
payload: BrandingNameUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the project name. Admin only. Empty name allowed (logo only mode)."""
name = payload.name.strip() if payload.name else ''
if len(name) > 50:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Name too long (max 50 characters)')
await set_setting_value(db, BRANDING_NAME_KEY, name)
logger.info(f'Admin {admin.telegram_id} updated branding name to: {name}')
# Return updated branding
custom_logo = has_custom_logo()
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
logo_url='/cabinet/branding/logo' if custom_logo else None,
logo_letter=logo_letter,
has_custom_logo=custom_logo,
)
@router.post('/logo', response_model=BrandingResponse)
async def upload_logo(
file: UploadFile = File(...),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Upload a custom logo. Admin only."""
# Validate content type
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid file type. Allowed: PNG, JPEG, WebP, SVG'
)
# Read file content
content = await file.read()
# Validate file size
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB',
)
# Ensure directory exists
ensure_branding_dir()
# Determine file extension from content type
ext_map = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/webp': '.webp',
'image/svg+xml': '.svg',
}
extension = ext_map.get(file.content_type, '.png')
# Remove old logo files with any extension
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
# Save new logo
logo_path = BRANDING_DIR / f'logo{extension}'
logo_path.write_bytes(content)
# Mark that we have a custom logo
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
logger.info(f'Admin {admin.telegram_id} uploaded new logo: {logo_path}')
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
logo_url='/cabinet/branding/logo',
logo_letter=logo_letter,
has_custom_logo=True,
)
@router.delete('/logo', response_model=BrandingResponse)
async def delete_logo(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Delete custom logo and revert to letter. Admin only."""
# Remove logo files
for old_file in BRANDING_DIR.glob('logo.*'):
old_file.unlink()
# Update setting
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
logger.info(f'Admin {admin.telegram_id} deleted custom logo')
# Get current name for response
name = await get_setting_value(db, BRANDING_NAME_KEY)
if name is None: # Only use fallback if not set at all (empty string is valid)
name = getattr(settings, 'CABINET_BRANDING_NAME', None) or os.getenv('VITE_APP_NAME', 'Cabinet')
logo_letter = name[0].upper() if name else 'C'
return BrandingResponse(
name=name,
logo_url=None,
logo_letter=logo_letter,
has_custom_logo=False,
)
# ============ Theme Colors Routes ============
def validate_hex_color(color: str) -> bool:
"""Validate hex color format."""
if not color or not isinstance(color, str):
return False
if not color.startswith('#'):
return False
hex_part = color[1:]
if len(hex_part) not in (3, 6):
return False
try:
int(hex_part, 16)
return True
except ValueError:
return False
@router.get('/colors', response_model=ThemeColorsResponse)
async def get_theme_colors(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get current theme colors.
This is a public endpoint - no authentication required.
"""
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
if colors_json:
try:
colors = json.loads(colors_json)
# Merge with defaults to ensure all fields exist
merged = {**DEFAULT_THEME_COLORS, **colors}
return ThemeColorsResponse(**merged)
except (json.JSONDecodeError, TypeError):
pass
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
@router.patch('/colors', response_model=ThemeColorsResponse)
async def update_theme_colors(
payload: ThemeColorsUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update theme colors. Admin only. Partial update supported."""
# Get current colors
colors_json = await get_setting_value(db, THEME_COLORS_KEY)
current_colors = DEFAULT_THEME_COLORS.copy()
if colors_json:
try:
current_colors.update(json.loads(colors_json))
except (json.JSONDecodeError, TypeError):
pass
# Update with new values (only non-None fields)
update_data = payload.model_dump(exclude_none=True)
# Validate hex colors
for key, value in update_data.items():
if not validate_hex_color(value):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Invalid hex color for {key}: {value}')
current_colors.update(update_data)
# Save to database
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors))
logger.info(f'Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}')
return ThemeColorsResponse(**current_colors)
@router.post('/colors/reset', response_model=ThemeColorsResponse)
async def reset_theme_colors(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset theme colors to defaults. Admin only."""
# Save default colors
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS))
logger.info(f'Admin {admin.telegram_id} reset theme colors to defaults')
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
# ============ Enabled Themes Routes ============
DEFAULT_ENABLED_THEMES = {'dark': True, 'light': True}
@router.get('/themes', response_model=EnabledThemesResponse)
async def get_enabled_themes(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get which themes are enabled.
This is a public endpoint - no authentication required.
"""
themes_json = await get_setting_value(db, ENABLED_THEMES_KEY)
if themes_json:
try:
themes = json.loads(themes_json)
return EnabledThemesResponse(**themes)
except (json.JSONDecodeError, TypeError):
pass
return EnabledThemesResponse(**DEFAULT_ENABLED_THEMES)
@router.patch('/themes', response_model=EnabledThemesResponse)
async def update_enabled_themes(
payload: EnabledThemesUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update which themes are enabled. Admin only. At least one theme must be enabled."""
# Get current settings
themes_json = await get_setting_value(db, ENABLED_THEMES_KEY)
current_themes = DEFAULT_ENABLED_THEMES.copy()
if themes_json:
try:
current_themes.update(json.loads(themes_json))
except (json.JSONDecodeError, TypeError):
pass
# Update with new values
update_data = payload.model_dump(exclude_none=True)
current_themes.update(update_data)
# Ensure at least one theme is enabled
if not current_themes.get('dark') and not current_themes.get('light'):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='At least one theme must be enabled')
# Save to database
await set_setting_value(db, ENABLED_THEMES_KEY, json.dumps(current_themes))
logger.info(f'Admin {admin.telegram_id} updated enabled themes: {current_themes}')
return EnabledThemesResponse(**current_themes)
# ============ Animation Routes ============
@router.get('/animation', response_model=AnimationEnabledResponse)
async def get_animation_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get animation enabled setting.
This is a public endpoint - no authentication required.
"""
animation_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
if animation_value is not None:
enabled = animation_value.lower() == 'true'
return AnimationEnabledResponse(enabled=enabled)
# Default: enabled
return AnimationEnabledResponse(enabled=True)
@router.patch('/animation', response_model=AnimationEnabledResponse)
async def update_animation_enabled(
payload: AnimationEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update animation enabled setting. Admin only."""
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set animation enabled: {payload.enabled}')
return AnimationEnabledResponse(enabled=payload.enabled)
# ============ Fullscreen Routes ============
@router.get('/fullscreen', response_model=FullscreenEnabledResponse)
async def get_fullscreen_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get fullscreen enabled setting.
This is a public endpoint - no authentication required.
"""
fullscreen_value = await get_setting_value(db, FULLSCREEN_ENABLED_KEY)
if fullscreen_value is not None:
enabled = fullscreen_value.lower() == 'true'
return FullscreenEnabledResponse(enabled=enabled)
# Default: disabled
return FullscreenEnabledResponse(enabled=False)
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
async def update_fullscreen_enabled(
payload: FullscreenEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update fullscreen enabled setting. Admin only."""
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}')
return FullscreenEnabledResponse(enabled=payload.enabled)
# ============ Email Auth Routes ============
@router.get('/email-auth', response_model=EmailAuthEnabledResponse)
async def get_email_auth_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get email auth enabled setting.
This is a public endpoint - no authentication required.
Controls whether email registration/login is available.
"""
email_auth_value = await get_setting_value(db, EMAIL_AUTH_ENABLED_KEY)
if email_auth_value is not None:
enabled = email_auth_value.lower() == 'true'
return EmailAuthEnabledResponse(enabled=enabled)
# Default: check config setting
return EmailAuthEnabledResponse(enabled=settings.is_cabinet_email_auth_enabled())
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
async def update_email_auth_enabled(
payload: EmailAuthEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update email auth enabled setting. Admin only."""
await set_setting_value(db, EMAIL_AUTH_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set email auth enabled: {payload.enabled}')
return EmailAuthEnabledResponse(enabled=payload.enabled)
# ============ Analytics Counters Routes ============
@router.get('/analytics', response_model=AnalyticsCountersResponse)
async def get_analytics_counters(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get analytics counter settings.
This is a public endpoint - no authentication required.
"""
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
)
@router.patch('/analytics', response_model=AnalyticsCountersResponse)
async def update_analytics_counters(
payload: AnalyticsCountersUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update analytics counter settings. Admin only. Partial update supported."""
if payload.yandex_metrika_id is not None:
value = payload.yandex_metrika_id.strip()
if value and not value.isdigit():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Yandex Metrika counter ID must be numeric',
)
await set_setting_value(db, YANDEX_METRIKA_ID_KEY, value)
if payload.google_ads_id is not None:
value = payload.google_ads_id.strip()
if value and not value.startswith('AW-'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Google Ads conversion ID must start with AW-',
)
await set_setting_value(db, GOOGLE_ADS_ID_KEY, value)
if payload.google_ads_label is not None:
await set_setting_value(db, GOOGLE_ADS_LABEL_KEY, payload.google_ads_label.strip())
logger.info(f'Admin {admin.telegram_id} updated analytics counters')
# Return current state
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
google_id = await get_setting_value(db, GOOGLE_ADS_ID_KEY) or ''
google_label = await get_setting_value(db, GOOGLE_ADS_LABEL_KEY) or ''
return AnalyticsCountersResponse(
yandex_metrika_id=yandex_id,
google_ads_id=google_id,
google_ads_label=google_label,
)
# ============ Lite Mode Routes ============
@router.get('/lite-mode', response_model=LiteModeEnabledResponse)
async def get_lite_mode_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get lite mode enabled setting.
This is a public endpoint - no authentication required.
When enabled, shows simplified dashboard with minimal features.
"""
lite_mode_value = await get_setting_value(db, LITE_MODE_ENABLED_KEY)
if lite_mode_value is not None:
enabled = lite_mode_value.lower() == 'true'
return LiteModeEnabledResponse(enabled=enabled)
# Default: disabled
return LiteModeEnabledResponse(enabled=False)
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
async def update_lite_mode_enabled(
payload: LiteModeEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update lite mode enabled setting. Admin only."""
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}')
return LiteModeEnabledResponse(enabled=payload.enabled)
-423
View File
@@ -1,423 +0,0 @@
"""Contests routes for cabinet - user participation in games/contests."""
import logging
import random
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.contest import (
create_attempt,
get_active_rounds,
get_attempt,
increment_winner_count,
)
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.models import SubscriptionStatus, User
from app.services.contest_rotation_service import (
GAME_ANAGRAM,
GAME_BLITZ,
GAME_CIPHER,
GAME_EMOJI,
GAME_LOCKS,
GAME_QUEST,
GAME_SERVER,
)
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/contests', tags=['Cabinet Contests'])
# ============ Schemas ============
class ContestInfo(BaseModel):
"""Contest/game info."""
id: int
slug: str
name: str
description: str | None = None
prize_type: str
prize_value: str
is_available: bool
already_played: bool = False
class ContestGameData(BaseModel):
"""Data for playing a contest game."""
round_id: int
game_type: str
game_data: dict[str, Any]
instructions: str
class ContestAnswerRequest(BaseModel):
"""Request to submit contest answer."""
round_id: int
answer: str
class ContestResult(BaseModel):
"""Result of contest attempt."""
is_winner: bool
message: str
prize_type: str | None = None
prize_value: str | None = None
# ============ Helpers ============
def _user_allowed(subscription) -> bool:
"""Check if user is allowed to participate in contests."""
if not subscription:
return False
return subscription.status in {
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
}
async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_value: str) -> str:
"""Award prize to winner."""
if prize_type == 'days':
try:
days = int(prize_value)
except ValueError:
return 'Error: invalid prize value'
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return 'Error: subscription not found'
subscription.end_date = subscription.end_date + timedelta(days=days)
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f'🎁 Extended subscription for user {user_id} by {days} days (contest prize)')
return f'Subscription extended by {days} days'
if prize_type == 'balance':
from app.database.crud.user import get_user_by_id
try:
amount = float(prize_value)
except ValueError:
return 'Error: invalid prize value'
user = await get_user_by_id(db, user_id)
if not user:
return 'Error: user not found'
user.balance += amount
await db.commit()
await db.refresh(user)
logger.info(f'🎁 Added {amount} to balance for user {user_id} (contest prize)')
return f'Balance increased by {amount}'
logger.warning(f'Unknown prize type: {prize_type}')
return f"Prize type '{prize_type}' not supported"
# ============ Routes ============
class ContestsCountResponse(BaseModel):
"""Count of available contests."""
count: int
@router.get('/count', response_model=ContestsCountResponse)
async def get_contests_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get count of contests available for the user."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
return ContestsCountResponse(count=0)
active_rounds = await get_active_rounds(db)
# Count unique available contests (not yet played)
count = 0
seen_templates = set()
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
continue
tpl_slug = rnd.template.slug if rnd.template else ''
if tpl_slug in seen_templates:
continue
seen_templates.add(tpl_slug)
# Check if user already played this round
attempt = await get_attempt(db, rnd.id, user.id)
if not attempt:
count += 1
return ContestsCountResponse(count=count)
@router.get('', response_model=list[ContestInfo])
async def get_contests(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available contests/games."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
# Group by template to avoid duplicates
unique_templates = {}
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
continue
tpl_slug = rnd.template.slug if rnd.template else ''
if tpl_slug not in unique_templates:
unique_templates[tpl_slug] = rnd
contests = []
for tpl_slug, rnd in unique_templates.items():
# Check if user already played this round
attempt = await get_attempt(db, rnd.id, user.id)
contests.append(
ContestInfo(
id=rnd.id,
slug=tpl_slug,
name=rnd.template.name if rnd.template else tpl_slug,
description=rnd.template.description if rnd.template else None,
prize_type=rnd.template.prize_type if rnd.template else 'days',
prize_value=rnd.template.prize_value if rnd.template else '1',
is_available=True,
already_played=attempt is not None,
)
)
return contests
@router.get('/{round_id}', response_model=ContestGameData)
async def get_contest_game(
round_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get game data for a specific contest round."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Contest round not found or already finished',
)
if not round_obj.template or not round_obj.template.is_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This contest is disabled',
)
# Check if already played
attempt = await get_attempt(db, round_id, user.id)
if attempt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You have already played this round',
)
tpl = round_obj.template
game_type = tpl.slug
game_data = {}
instructions = ''
if game_type == GAME_QUEST:
rows = round_obj.payload.get('rows', 3)
cols = round_obj.payload.get('cols', 3)
secret = random.randint(0, rows * cols - 1)
game_data = {
'rows': rows,
'cols': cols,
'secret': secret,
'grid_size': rows * cols,
}
instructions = 'Select one of the nodes in the grid. Find the hidden server!'
elif game_type == GAME_LOCKS:
total = round_obj.payload.get('total', 20)
secret = random.randint(0, total - 1)
game_data = {
'total': total,
'secret': secret,
}
instructions = 'Find the unlocked button among the locks!'
elif game_type == GAME_SERVER:
flags = round_obj.payload.get('flags') or []
shuffled_flags = flags.copy()
random.shuffle(shuffled_flags)
game_data = {
'flags': shuffled_flags,
}
instructions = 'Choose a server by clicking on a flag!'
elif game_type == GAME_CIPHER:
question = round_obj.payload.get('question', '')
game_data = {
'question': question,
'input_type': 'text',
}
instructions = 'Decrypt the cipher and enter the answer!'
elif game_type == GAME_EMOJI:
question = round_obj.payload.get('question', '🤔')
emoji_list = question.split()
random.shuffle(emoji_list)
game_data = {
'question': ' '.join(emoji_list),
'input_type': 'text',
}
instructions = 'Guess the service by emojis!'
elif game_type == GAME_ANAGRAM:
letters = round_obj.payload.get('letters', '')
game_data = {
'letters': letters,
'input_type': 'text',
}
instructions = 'Make a word from the given letters!'
elif game_type == GAME_BLITZ:
game_data = {
'button_text': "I'm here!",
}
instructions = 'Click the button as fast as you can!'
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unknown contest type',
)
return ContestGameData(
round_id=round_id,
game_type=game_type,
game_data=game_data,
instructions=instructions,
)
@router.post('/{round_id}/answer', response_model=ContestResult)
async def submit_contest_answer(
round_id: int,
request: ContestAnswerRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit answer for a contest round."""
subscription = await get_subscription_by_user_id(db, user.id)
if not _user_allowed(subscription):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Contests are only available for users with active or trial subscriptions',
)
active_rounds = await get_active_rounds(db)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Contest round not found or already finished',
)
# Check if already played
attempt = await get_attempt(db, round_id, user.id)
if attempt:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You have already played this round',
)
tpl = round_obj.template
answer = request.answer
is_winner = False
# Determine if winner based on game type
if tpl.slug == GAME_SERVER:
flags = round_obj.payload.get('flags') or []
secret_idx = round_obj.payload.get('secret_idx')
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ''
is_winner = answer == correct_flag
elif tpl.slug in {GAME_QUEST, GAME_LOCKS}:
try:
parts = answer.split('_')
if len(parts) >= 2:
idx = int(parts[0])
secret = int(parts[1])
is_winner = idx == secret
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_BLITZ:
is_winner = answer.lower() == 'blitz'
elif tpl.slug in {GAME_CIPHER, GAME_EMOJI, GAME_ANAGRAM}:
correct = (round_obj.payload.get('answer') or '').upper()
is_winner = correct and answer.upper() == correct
# Record attempt
await create_attempt(db, round_id=round_obj.id, user_id=user.id, answer=str(answer), is_winner=is_winner)
if is_winner:
await increment_winner_count(db, round_obj)
prize_text = await _award_prize(db, user.id, tpl.prize_type, tpl.prize_value)
return ContestResult(
is_winner=True,
message=f'🎉 Congratulations! You won! {prize_text}',
prize_type=tpl.prize_type,
prize_value=tpl.prize_value,
)
lose_messages = {
GAME_QUEST: ['Empty node', 'Wrong server', 'Try another'],
GAME_LOCKS: ['Locked', 'No access', 'Try again'],
GAME_SERVER: ['Server overloaded', 'No response', 'Try tomorrow'],
}
messages = lose_messages.get(tpl.slug, ['Incorrect', 'Try again next round'])
return ContestResult(
is_winner=False,
message=random.choice(messages),
)
-310
View File
@@ -1,310 +0,0 @@
"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.rules import get_current_rules_content, get_rules_by_language
from app.database.models import User
from app.services.faq_service import FaqService
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
_LANGUAGE_META: dict[str, tuple[str, str]] = {
'ru': ('Русский', '🇷🇺'),
'en': ('English', '🇬🇧'),
'ua': ('Українська', '🇺🇦'),
'zh': ('中文', '🇨🇳'),
'fa': ('فارسی', '🇮🇷'),
}
def _normalize_language_code(value: str | None) -> str:
return (value or '').strip().lower().split('-', 1)[0]
def _get_available_language_codes() -> list[str]:
codes: list[str] = []
seen: set[str] = set()
for code in settings.get_available_languages():
normalized = _normalize_language_code(code)
if not normalized or normalized in seen:
continue
seen.add(normalized)
codes.append(normalized)
return codes
# ============ Schemas ============
class FaqPageResponse(BaseModel):
"""FAQ page."""
id: int
title: str
content: str
order: int
class RulesResponse(BaseModel):
"""Service rules."""
content: str
updated_at: str | None = None
class PrivacyPolicyResponse(BaseModel):
"""Privacy policy."""
content: str
updated_at: str | None = None
class PublicOfferResponse(BaseModel):
"""Public offer."""
content: str
updated_at: str | None = None
class ServiceInfoResponse(BaseModel):
"""General service info."""
name: str
description: str | None = None
support_email: str | None = None
support_telegram: str | None = None
website: str | None = None
class SupportConfigResponse(BaseModel):
"""Support/tickets configuration for miniapp."""
tickets_enabled: bool
support_type: str # "tickets", "profile", "url"
support_url: str | None = None
support_username: str | None = None
# ============ Routes ============
@router.get('/faq', response_model=list[FaqPageResponse])
async def get_faq_pages(
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of FAQ pages."""
requested_lang = FaqService.normalize_language(language)
pages = await FaqService.get_pages(
db,
requested_lang,
include_inactive=False, # Only active pages for cabinet
fallback=True,
)
return [
FaqPageResponse(
id=page.id,
title=page.title,
content=page.content or '',
order=page.display_order or 0,
)
for page in pages
]
@router.get('/faq/{page_id}', response_model=FaqPageResponse)
async def get_faq_page(
page_id: int,
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get a specific FAQ page by ID."""
requested_lang = FaqService.normalize_language(language)
page = await FaqService.get_page(
db,
page_id,
requested_lang,
include_inactive=False,
fallback=True,
)
if not page:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='FAQ page not found',
)
return FaqPageResponse(
id=page.id,
title=page.title,
content=page.content or '',
order=page.display_order or 0,
)
@router.get('/rules', response_model=RulesResponse)
async def get_rules(
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get service rules - uses same function as bot."""
requested_lang = language.split('-')[0].lower()
# Use the same function as bot to ensure consistent content
content = await get_current_rules_content(db, requested_lang)
# Try to get updated_at from DB record
rules = await get_rules_by_language(db, requested_lang)
updated_at = None
if rules and rules.updated_at:
updated_at = rules.updated_at.isoformat()
return RulesResponse(content=content, updated_at=updated_at)
@router.get('/privacy-policy', response_model=PrivacyPolicyResponse)
async def get_privacy_policy(
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get privacy policy."""
requested_lang = PrivacyPolicyService.normalize_language(language)
policy = await PrivacyPolicyService.get_policy(db, requested_lang, fallback=True)
if policy and policy.content:
updated_at = policy.updated_at.isoformat() if policy.updated_at else None
return PrivacyPolicyResponse(content=policy.content, updated_at=updated_at)
# Return default policy if none found
return PrivacyPolicyResponse(
content="""# Политика конфиденциальности
Мы уважаем вашу конфиденциальность и защищаем ваши персональные данные.
""",
updated_at=None,
)
@router.get('/public-offer', response_model=PublicOfferResponse)
async def get_public_offer(
language: str = Query('ru', min_length=2, max_length=10),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get public offer."""
requested_lang = PublicOfferService.normalize_language(language)
offer = await PublicOfferService.get_offer(db, requested_lang, fallback=True)
if offer and offer.content:
updated_at = offer.updated_at.isoformat() if offer.updated_at else None
return PublicOfferResponse(content=offer.content, updated_at=updated_at)
# Return default offer if none found
return PublicOfferResponse(
content="""# Публичная оферта
Условия использования сервиса.
""",
updated_at=None,
)
@router.get('/service', response_model=ServiceInfoResponse)
async def get_service_info():
"""Get general service information."""
return ServiceInfoResponse(
name=getattr(settings, 'SERVICE_NAME', None) or getattr(settings, 'BOT_NAME', 'VPN Service'),
description=getattr(settings, 'SERVICE_DESCRIPTION', None),
support_email=getattr(settings, 'SUPPORT_EMAIL', None),
support_telegram=getattr(settings, 'SUPPORT_USERNAME', None) or getattr(settings, 'SUPPORT_TELEGRAM', None),
website=getattr(settings, 'WEBSITE_URL', None),
)
@router.get('/languages')
async def get_available_languages():
"""Get list of available languages."""
codes = _get_available_language_codes()
default_language = _normalize_language_code(getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru')
return {
'languages': [
{
'code': code,
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
}
for code in codes
],
'default': default_language,
}
@router.get('/user/language')
async def get_user_language(
user: User = Depends(get_current_cabinet_user),
):
"""Get current user's language."""
return {'language': user.language or 'ru'}
@router.patch('/user/language')
async def update_user_language(
request: dict[str, str],
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's language preference."""
requested_language = _normalize_language_code(request.get('language', 'ru'))
available_languages = _get_available_language_codes()
if requested_language not in available_languages:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
)
user.language = requested_language
await db.commit()
await db.refresh(user)
return {'language': user.language}
@router.get('/support-config', response_model=SupportConfigResponse)
async def get_support_config():
"""Get support/tickets configuration for cabinet."""
# Use SUPPORT_SYSTEM_MODE setting (configurable from admin panel)
support_mode = settings.get_support_system_mode() # returns: tickets, contact, or both
# Map support mode to support type for frontend
# - "tickets" mode -> tickets only, no contact
# - "contact" mode -> contact only (profile), no tickets
# - "both" mode -> tickets enabled, contact available as fallback
if support_mode == 'tickets':
tickets_enabled = True
support_type = 'tickets'
elif support_mode == 'contact':
tickets_enabled = False
support_type = 'profile'
else: # both
tickets_enabled = True
support_type = 'tickets'
return SupportConfigResponse(
tickets_enabled=tickets_enabled,
support_type=support_type,
support_url=None, # Cabinet doesn't use custom URLs
support_username=settings.SUPPORT_USERNAME, # Always return for fallback
)
-196
View File
@@ -1,196 +0,0 @@
"""Media upload/download routes for cabinet tickets."""
import logging
import mimetypes
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
from pydantic import BaseModel
from app.config import settings
from app.database.models import User
from ..dependencies import get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/media', tags=['Cabinet Media'])
ALLOWED_MEDIA_TYPES = {'photo', 'video', 'document'}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
class MediaUploadResponse(BaseModel):
"""Response after successful media upload."""
media_type: str
file_id: str
file_unique_id: str | None = None
media_url: str
def _resolve_target_chat_id() -> int:
"""Get chat ID for uploading files (notification channel or first admin)."""
chat_id = settings.get_admin_notifications_chat_id()
if chat_id is not None:
return chat_id
admin_ids = settings.get_admin_ids()
if admin_ids:
return admin_ids[0]
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='No chat configured for file uploads',
)
def _build_media_url(request: Request, file_id: str) -> str:
"""Build URL for downloading media."""
return str(request.url_for('cabinet_download_media', file_id=file_id))
@router.post('/upload', response_model=MediaUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_media(
request: Request,
user: User = Depends(get_current_cabinet_user),
file: UploadFile = File(...),
media_type: str = Form('photo', description='File type: photo, video, or document'),
):
"""
Upload media file for use in ticket messages.
Returns file_id that can be used when creating ticket or adding message.
"""
media_type_normalized = (media_type or '').strip().lower()
if media_type_normalized not in ALLOWED_MEDIA_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Unsupported media type. Allowed: {", ".join(ALLOWED_MEDIA_TYPES)}',
)
# Read and validate file
file_bytes = await file.read()
if not file_bytes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='File is empty',
)
if len(file_bytes) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB',
)
# Validate content type for photos
if media_type_normalized == 'photo':
allowed_image_types = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
if file.content_type and file.content_type not in allowed_image_types:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid image type. Allowed: JPEG, PNG, GIF, WebP',
)
target_chat_id = _resolve_target_chat_id()
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
if media_type_normalized == 'photo':
message = await bot.send_photo(
chat_id=target_chat_id,
photo=upload,
)
media = message.photo[-1]
elif media_type_normalized == 'video':
message = await bot.send_video(
chat_id=target_chat_id,
video=upload,
)
media = message.video
else:
message = await bot.send_document(
chat_id=target_chat_id,
document=upload,
)
media = message.document
media_url = _build_media_url(request, media.file_id)
logger.info(f'User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}')
return MediaUploadResponse(
media_type=media_type_normalized,
file_id=media.file_id,
file_unique_id=getattr(media, 'file_unique_id', None),
media_url=media_url,
)
except HTTPException:
raise
except Exception as error:
logger.error(f'Failed to upload media for user {user.telegram_id}: {error}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to upload media',
) from error
finally:
await bot.session.close()
@router.get('/{file_id}', name='cabinet_download_media')
async def download_media(
file_id: str,
) -> Response:
"""
Download media file by file_id.
Used to display images/documents in ticket messages.
"""
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
try:
file = await bot.get_file(file_id)
if not file.file_path:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Media file not found',
)
buffer = await bot.download_file(file.file_path)
if hasattr(buffer, 'seek'):
buffer.seek(0)
content = buffer.read() if hasattr(buffer, 'read') else bytes(buffer)
filename = file.file_path.split('/')[-1]
media_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
return Response(
content=content,
media_type=media_type,
headers={
'Content-Disposition': f'inline; filename={filename}',
'Cache-Control': 'public, max-age=86400', # Cache for 24 hours
},
)
except HTTPException:
raise
except Exception as error:
logger.error(f'Failed to download media {file_id}: {error}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to download media',
) from error
finally:
await bot.session.close()
-151
View File
@@ -1,151 +0,0 @@
"""Notification settings routes for cabinet."""
import logging
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/notifications', tags=['Cabinet Notifications'])
# ============ Schemas ============
class NotificationSettingsResponse(BaseModel):
"""User notification settings."""
subscription_expiry_enabled: bool = True
subscription_expiry_days: int = 3
traffic_warning_enabled: bool = True
traffic_warning_percent: int = 80
balance_low_enabled: bool = True
balance_low_threshold: int = 100 # kopeks
news_enabled: bool = True
promo_offers_enabled: bool = True
class NotificationSettingsUpdate(BaseModel):
"""Update notification settings."""
subscription_expiry_enabled: bool | None = None
subscription_expiry_days: int | None = Field(None, ge=1, le=30)
traffic_warning_enabled: bool | None = None
traffic_warning_percent: int | None = Field(None, ge=50, le=99)
balance_low_enabled: bool | None = None
balance_low_threshold: int | None = Field(None, ge=0)
news_enabled: bool | None = None
promo_offers_enabled: bool | None = None
# ============ Helpers ============
def _get_notification_settings(user: User) -> dict[str, Any]:
"""Get notification settings from user object."""
# Try to get from user's settings field or use defaults
settings_data = getattr(user, 'notification_settings', None) or {}
return {
'subscription_expiry_enabled': settings_data.get('subscription_expiry_enabled', True),
'subscription_expiry_days': settings_data.get('subscription_expiry_days', 3),
'traffic_warning_enabled': settings_data.get('traffic_warning_enabled', True),
'traffic_warning_percent': settings_data.get('traffic_warning_percent', 80),
'balance_low_enabled': settings_data.get('balance_low_enabled', True),
'balance_low_threshold': settings_data.get('balance_low_threshold', 100),
'news_enabled': settings_data.get('news_enabled', True),
'promo_offers_enabled': settings_data.get('promo_offers_enabled', True),
}
def _update_notification_settings(user: User, updates: dict[str, Any]) -> dict[str, Any]:
"""Update notification settings on user object."""
current_settings = _get_notification_settings(user)
for key, value in updates.items():
if value is not None:
current_settings[key] = value
return current_settings
# ============ Routes ============
@router.get('', response_model=NotificationSettingsResponse)
async def get_notification_settings(
user: User = Depends(get_current_cabinet_user),
):
"""Get user's notification settings."""
settings = _get_notification_settings(user)
return NotificationSettingsResponse(**settings)
@router.patch('', response_model=NotificationSettingsResponse)
async def update_notification_settings(
request: NotificationSettingsUpdate,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's notification settings."""
updates = request.model_dump(exclude_unset=True)
if not updates:
# No updates provided, return current settings
settings = _get_notification_settings(user)
return NotificationSettingsResponse(**settings)
# Update settings
new_settings = _update_notification_settings(user, updates)
# Store in user object
if not hasattr(user, 'notification_settings') or user.notification_settings is None:
user.notification_settings = {}
user.notification_settings = new_settings
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
return NotificationSettingsResponse(**new_settings)
@router.post('/test')
async def send_test_notification(
user: User = Depends(get_current_cabinet_user),
):
"""Send a test notification to the user."""
# This would typically trigger a notification via Telegram bot
# For now, just return success
return {
'success': True,
'message': 'Test notification request received. You will receive a test message shortly.',
}
@router.get('/history')
async def get_notification_history(
limit: int = 20,
offset: int = 0,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's notification history."""
# For now, return empty list - notification history can be implemented later
# when there's a notification log table
return {
'notifications': [],
'total': 0,
'limit': limit,
'offset': offset,
}
-165
View File
@@ -1,165 +0,0 @@
"""OAuth 2.0 authentication routes for cabinet."""
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import (
create_user_by_oauth,
get_user_by_email,
get_user_by_oauth_provider,
set_user_oauth_provider_id,
)
from app.database.models import User
from ..auth.oauth_providers import (
OAuthUserInfo,
generate_oauth_state,
get_provider,
validate_oauth_state,
)
from ..dependencies import get_cabinet_db
from ..schemas.auth import AuthResponse
from .auth import _create_auth_response, _store_refresh_token
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
await db.commit()
auth_response = _create_auth_response(user)
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
return auth_response
# --- Schemas ---
class OAuthProviderInfo(BaseModel):
name: str
display_name: str
class OAuthProvidersResponse(BaseModel):
providers: list[OAuthProviderInfo]
class OAuthAuthorizeResponse(BaseModel):
authorize_url: str
state: str
class OAuthCallbackRequest(BaseModel):
code: str = Field(..., description='Authorization code from provider')
state: str = Field(..., description='CSRF state token')
# --- Endpoints ---
@router.get('/providers', response_model=OAuthProvidersResponse)
async def get_oauth_providers():
"""Get list of enabled OAuth providers."""
providers_config = settings.get_oauth_providers_config()
providers = [
OAuthProviderInfo(name=name, display_name=cfg['display_name'])
for name, cfg in providers_config.items()
if cfg['enabled']
]
return OAuthProvidersResponse(providers=providers)
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
async def get_oauth_authorize_url(provider: str):
"""Get authorization URL for an OAuth provider."""
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'OAuth provider "{provider}" is not enabled',
)
state = await generate_oauth_state(provider)
authorize_url = oauth_provider.get_authorization_url(state)
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
@router.post('/{provider}/callback', response_model=AuthResponse)
async def oauth_callback(
provider: str,
request: OAuthCallbackRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
# 1. Validate CSRF state
if not await validate_oauth_state(request.state, provider):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired OAuth state',
)
# 2. Get provider instance
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'OAuth provider "{provider}" is not enabled',
)
# 3. Exchange code for tokens
try:
token_data = await oauth_provider.exchange_code(request.code)
except Exception as exc:
logger.error('OAuth code exchange failed for %s: %s', provider, exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to exchange authorization code',
) from exc
# 4. Fetch user info from provider
try:
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
except Exception as exc:
logger.error('OAuth user info fetch failed for %s: %s', provider, exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to fetch user information from provider',
) from exc
# 5. Find user by provider ID
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if user:
logger.info('OAuth login via %s for existing user %s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
# 6. Find user by email (if verified) and link provider
if user_info.email and user_info.email_verified:
user = await get_user_by_email(db, user_info.email)
if user:
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
logger.info('OAuth login via %s linked to existing email user %s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
# 7. Create new user
user = await create_user_by_oauth(
db=db,
provider=provider,
provider_id=user_info.provider_id,
email=user_info.email if user_info.email_verified else None,
email_verified=user_info.email_verified,
first_name=user_info.first_name,
last_name=user_info.last_name,
username=user_info.username,
)
logger.info('OAuth new user created via %s with id=%s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
-365
View File
@@ -1,365 +0,0 @@
"""Polls routes for cabinet - user participation in polls/surveys."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.poll import (
get_poll_response_by_id,
record_poll_answer,
)
from app.database.models import Poll, PollQuestion, PollResponse, User
from app.services.poll_service import get_next_question, get_question_option, reward_user_for_poll
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/polls', tags=['Cabinet Polls'])
# ============ Schemas ============
class PollOptionResponse(BaseModel):
"""Poll option."""
id: int
text: str
order: int
class PollQuestionResponse(BaseModel):
"""Poll question with options."""
id: int
text: str
order: int
options: list[PollOptionResponse]
class PollInfo(BaseModel):
"""Poll info for user."""
id: int
response_id: int
title: str
description: str | None = None
total_questions: int
answered_questions: int
is_completed: bool
reward_amount: int | None = None
class PollStartResponse(BaseModel):
"""Response when starting a poll."""
response_id: int
current_question_index: int
total_questions: int
question: PollQuestionResponse
class AnswerRequest(BaseModel):
"""Request to answer a poll question."""
option_id: int
class AnswerResponse(BaseModel):
"""Response after answering."""
success: bool
is_completed: bool
next_question: PollQuestionResponse | None = None
current_question_index: int | None = None
total_questions: int
reward_granted: int | None = None
message: str | None = None
# ============ Helpers ============
def _question_to_response(question: PollQuestion) -> PollQuestionResponse:
"""Convert question model to response."""
options = [
PollOptionResponse(
id=opt.id,
text=opt.text,
order=opt.order,
)
for opt in sorted(question.options, key=lambda o: o.order)
]
return PollQuestionResponse(
id=question.id,
text=question.text,
order=question.order,
options=options,
)
# ============ Routes ============
class PollsCountResponse(BaseModel):
"""Count of available polls."""
count: int
@router.get('/count', response_model=PollsCountResponse)
async def get_polls_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get count of polls available for the user."""
result = await db.execute(
select(PollResponse)
.where(PollResponse.user_id == user.id)
.where(PollResponse.completed_at.is_(None)) # Only incomplete polls
)
responses = result.scalars().all()
return PollsCountResponse(count=len(responses))
@router.get('', response_model=list[PollInfo])
async def get_available_polls(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of polls available for the user."""
# Get user's poll responses with eager loading of relationships
result = await db.execute(
select(PollResponse)
.where(PollResponse.user_id == user.id)
.options(
selectinload(PollResponse.poll).selectinload(Poll.questions),
selectinload(PollResponse.answers),
)
.order_by(PollResponse.created_at.desc())
)
responses = result.scalars().all()
polls = []
for response in responses:
if not response.poll:
continue
answered_count = len(response.answers) if response.answers else 0
total_questions = len(response.poll.questions) if response.poll.questions else 0
# Convert kopeks to rubles for display
reward_amount = None
if response.poll.reward_amount_kopeks:
reward_amount = response.poll.reward_amount_kopeks // 100
polls.append(
PollInfo(
id=response.poll.id,
response_id=response.id,
title=response.poll.title,
description=response.poll.description,
total_questions=total_questions,
answered_questions=answered_count,
is_completed=response.completed_at is not None,
reward_amount=reward_amount,
)
)
return polls
@router.get('/{response_id}', response_model=PollInfo)
async def get_poll_details(
response_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get details of a specific poll response."""
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Poll not found',
)
if not response.poll:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Poll data not available',
)
answered_count = len(response.answers) if response.answers else 0
total_questions = len(response.poll.questions) if response.poll.questions else 0
# Convert kopeks to rubles for display
reward_amount = None
if response.poll.reward_amount_kopeks:
reward_amount = response.poll.reward_amount_kopeks // 100
return PollInfo(
id=response.poll.id,
response_id=response.id,
title=response.poll.title,
description=response.poll.description,
total_questions=total_questions,
answered_questions=answered_count,
is_completed=response.completed_at is not None,
reward_amount=reward_amount,
)
@router.post('/{response_id}/start', response_model=PollStartResponse)
async def start_poll(
response_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Start or continue a poll."""
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Poll not found',
)
if response.completed_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This poll has already been completed',
)
if not response.poll or not response.poll.questions:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Poll is not available',
)
# Mark as started if not already
if not response.started_at:
response.started_at = datetime.utcnow()
await db.commit()
# Get next unanswered question
index, question = await get_next_question(response)
if not question:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No questions available',
)
return PollStartResponse(
response_id=response.id,
current_question_index=index,
total_questions=len(response.poll.questions),
question=_question_to_response(question),
)
@router.post('/{response_id}/questions/{question_id}/answer', response_model=AnswerResponse)
async def answer_question(
response_id: int,
question_id: int,
request: AnswerRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit answer for a poll question."""
response = await get_poll_response_by_id(db, response_id)
if not response or response.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Poll not found',
)
if response.completed_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This poll has already been completed',
)
if not response.poll:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Poll is not available',
)
# Find the question
question = next((q for q in response.poll.questions if q.id == question_id), None)
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Question not found',
)
# Validate option
option = await get_question_option(question, request.option_id)
if not option:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid answer option',
)
# Record the answer
await record_poll_answer(
db,
response_id=response.id,
question_id=question.id,
option_id=option.id,
)
# Refresh to get updated answers
try:
await db.refresh(response, attribute_names=['answers'])
except Exception:
response = await get_poll_response_by_id(db, response_id)
if not response:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to process answer',
)
# Get next question
index, next_question = await get_next_question(response)
total_questions = len(response.poll.questions)
if next_question:
# More questions to answer
return AnswerResponse(
success=True,
is_completed=False,
next_question=_question_to_response(next_question),
current_question_index=index,
total_questions=total_questions,
)
# Poll completed
response.completed_at = datetime.utcnow()
await db.commit()
# Award reward if any
reward_amount = await reward_user_for_poll(db, response)
message = 'Thank you for completing the poll!'
if reward_amount:
message += f' Reward of {settings.format_price(reward_amount)} has been added to your balance.'
return AnswerResponse(
success=True,
is_completed=True,
total_questions=total_questions,
reward_granted=reward_amount,
message=message,
)
-415
View File
@@ -1,415 +0,0 @@
"""Promo offers routes for cabinet - personal discounts and offers."""
import logging
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.discount_offer import (
get_offer_by_id,
mark_offer_claimed,
)
from app.database.crud.promo_group import get_auto_assign_promo_groups
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.database.models import DiscountOffer, User
from app.services.promo_offer_service import promo_offer_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/promo', tags=['Cabinet Promo'])
# ============ Schemas ============
class PromoOfferInfo(BaseModel):
"""Promo offer info."""
id: int
notification_type: str
discount_percent: int | None = None
effect_type: str
expires_at: datetime
is_active: bool
is_claimed: bool
claimed_at: datetime | None = None
extra_data: dict[str, Any] | None = None
class ActiveDiscountInfo(BaseModel):
"""User's active discount info."""
discount_percent: int
source: str | None = None
expires_at: datetime | None = None
is_active: bool
class ClaimOfferRequest(BaseModel):
"""Request to claim an offer."""
offer_id: int
class ClaimOfferResponse(BaseModel):
"""Response after claiming offer."""
success: bool
message: str
discount_percent: int | None = None
expires_at: datetime | None = None
class PromoGroupDiscounts(BaseModel):
"""User's promo group discounts."""
group_name: str | None = None
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: dict[str, int] = {}
class LoyaltyTierInfo(BaseModel):
"""Info about a single loyalty tier (promo group)."""
id: int
name: str
threshold_rubles: float
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
period_discounts: dict[str, int] = {}
is_current: bool = False
is_achieved: bool = False
class LoyaltyTiersResponse(BaseModel):
"""Response with all loyalty tiers and user progress."""
tiers: list[LoyaltyTierInfo]
current_spent_rubles: float
current_tier_name: str | None = None
next_tier_name: str | None = None
next_tier_threshold_rubles: float | None = None
progress_percent: float = 0
# ============ Routes ============
@router.get('/offers', response_model=list[PromoOfferInfo])
async def get_promo_offers(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of available promo offers for the user."""
now = datetime.utcnow()
result = await db.execute(
select(DiscountOffer)
.where(
and_(
DiscountOffer.user_id == user.id,
DiscountOffer.expires_at > now,
)
)
.order_by(DiscountOffer.created_at.desc())
)
offers = result.scalars().all()
return [
PromoOfferInfo(
id=offer.id,
notification_type=offer.notification_type or '',
discount_percent=offer.discount_percent,
effect_type=offer.effect_type or 'percent_discount',
expires_at=offer.expires_at,
is_active=offer.is_active and offer.claimed_at is None,
is_claimed=offer.claimed_at is not None,
claimed_at=offer.claimed_at,
extra_data=offer.extra_data,
)
for offer in offers
]
@router.get('/active-discount', response_model=ActiveDiscountInfo)
async def get_active_discount(
user: User = Depends(get_current_cabinet_user),
):
"""Get user's currently active discount."""
discount_percent = user.promo_offer_discount_percent or 0
expires_at = user.promo_offer_discount_expires_at
source = user.promo_offer_discount_source
now = datetime.utcnow()
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
return ActiveDiscountInfo(
discount_percent=discount_percent if is_active else 0,
source=source,
expires_at=expires_at,
is_active=is_active,
)
@router.get('/group-discounts', response_model=PromoGroupDiscounts)
async def get_promo_group_discounts(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's promo group discounts."""
await db.refresh(user, ['promo_group', 'user_promo_groups'])
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
if not promo_group:
return PromoGroupDiscounts()
# Get period discounts
period_discounts = {}
raw_period_discounts = getattr(promo_group, 'period_discounts', None)
if isinstance(raw_period_discounts, dict):
for key, value in raw_period_discounts.items():
try:
period_discounts[str(key)] = int(value)
except (TypeError, ValueError):
continue
return PromoGroupDiscounts(
group_name=promo_group.name,
server_discount_percent=promo_group.server_discount_percent or 0,
traffic_discount_percent=promo_group.traffic_discount_percent or 0,
device_discount_percent=promo_group.device_discount_percent or 0,
period_discounts=period_discounts,
)
@router.get('/loyalty-tiers', response_model=LoyaltyTiersResponse)
async def get_loyalty_tiers(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get all loyalty tiers (promo groups with auto-assign thresholds) and user's progress."""
# Get user's total spent
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
total_spent_rubles = total_spent_kopeks / 100
# Get user's current promo group
await db.refresh(user, ['promo_group', 'user_promo_groups'])
current_promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
current_tier_name = current_promo_group.name if current_promo_group else None
# Get all auto-assign promo groups (sorted by threshold ascending)
auto_groups = await get_auto_assign_promo_groups(db)
tiers: list[LoyaltyTierInfo] = []
next_tier_name: str | None = None
next_tier_threshold: float | None = None
for group in auto_groups:
threshold_kopeks = group.auto_assign_total_spent_kopeks or 0
threshold_rubles = threshold_kopeks / 100
is_achieved = total_spent_kopeks >= threshold_kopeks
is_current = current_promo_group and current_promo_group.id == group.id
# Get period discounts
period_discounts = {}
raw_period_discounts = getattr(group, 'period_discounts', None)
if isinstance(raw_period_discounts, dict):
for key, value in raw_period_discounts.items():
try:
period_discounts[str(key)] = int(value)
except (TypeError, ValueError):
continue
tiers.append(
LoyaltyTierInfo(
id=group.id,
name=group.name,
threshold_rubles=threshold_rubles,
server_discount_percent=group.server_discount_percent or 0,
traffic_discount_percent=group.traffic_discount_percent or 0,
device_discount_percent=group.device_discount_percent or 0,
period_discounts=period_discounts,
is_current=is_current,
is_achieved=is_achieved,
)
)
# Find next tier (first not achieved)
if not is_achieved and next_tier_name is None:
next_tier_name = group.name
next_tier_threshold = threshold_rubles
# Calculate progress to next tier
progress_percent = 0.0
if next_tier_threshold and next_tier_threshold > 0:
progress_percent = min(100.0, (total_spent_rubles / next_tier_threshold) * 100)
elif tiers and all(t.is_achieved for t in tiers):
# All tiers achieved
progress_percent = 100.0
return LoyaltyTiersResponse(
tiers=tiers,
current_spent_rubles=total_spent_rubles,
current_tier_name=current_tier_name,
next_tier_name=next_tier_name,
next_tier_threshold_rubles=next_tier_threshold,
progress_percent=progress_percent,
)
@router.post('/claim', response_model=ClaimOfferResponse)
async def claim_promo_offer(
request: ClaimOfferRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Claim a promo offer."""
offer = await get_offer_by_id(db, request.offer_id)
if not offer or offer.user_id != user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Offer not found',
)
now = datetime.utcnow()
if offer.claimed_at is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This offer has already been claimed',
)
if not offer.is_active or offer.expires_at <= now:
offer.is_active = False
await db.commit()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This offer has expired',
)
effect_type = (offer.effect_type or 'percent_discount').lower()
# Handle test access offers
if effect_type == 'test_access':
await db.refresh(user, ['subscription'])
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
db,
user,
offer,
)
if not success:
error_messages = {
'subscription_missing': 'Active subscription required for this offer',
'squads_missing': 'Could not determine servers for test access',
'already_connected': 'These servers are already connected',
'remnawave_sync_failed': 'Failed to connect servers. Please try again later',
}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_messages.get(error_code, 'Failed to activate offer'),
)
await mark_offer_claimed(
db,
offer,
details={
'context': 'test_access_claim',
'new_squads': newly_added,
'expires_at': expires_at.isoformat() if expires_at else None,
},
)
return ClaimOfferResponse(
success=True,
message=f'Test access activated until {expires_at.strftime("%Y-%m-%d %H:%M") if expires_at else "unlimited"}',
expires_at=expires_at,
)
# Handle discount offers
discount_percent = int(offer.discount_percent or 0)
if discount_percent <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid offer',
)
user.promo_offer_discount_percent = discount_percent
user.promo_offer_discount_source = offer.notification_type
user.updated_at = now
# Calculate expiration
extra_data = offer.extra_data or {}
raw_duration = extra_data.get('active_discount_hours')
template_id = extra_data.get('template_id')
if raw_duration in (None, '') and template_id:
try:
template = await get_promo_offer_template_by_id(db, int(template_id))
except (ValueError, TypeError):
template = None
if template and template.active_discount_hours:
raw_duration = template.active_discount_hours
try:
duration_hours = int(raw_duration) if raw_duration is not None else None
except (TypeError, ValueError):
duration_hours = None
if duration_hours and duration_hours > 0:
discount_expires_at = now + timedelta(hours=duration_hours)
else:
discount_expires_at = None
user.promo_offer_discount_expires_at = discount_expires_at
await mark_offer_claimed(
db,
offer,
details={
'context': 'discount_claim',
'discount_percent': discount_percent,
'discount_expires_at': discount_expires_at.isoformat() if discount_expires_at else None,
},
)
await db.refresh(user)
expires_text = ''
if discount_expires_at:
expires_text = f' Valid until {discount_expires_at.strftime("%Y-%m-%d %H:%M")}'
return ClaimOfferResponse(
success=True,
message=f'Discount of {discount_percent}% activated!{expires_text}',
discount_percent=discount_percent,
expires_at=discount_expires_at,
)
@router.delete('/active-discount')
async def clear_active_discount(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Clear user's active discount."""
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
return {'message': 'Active discount cleared'}
-124
View File
@@ -1,124 +0,0 @@
"""Promo code routes for cabinet."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.promocode_service import PromoCodeService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/promocode', tags=['Cabinet Promocode'])
class PromocodeActivateRequest(BaseModel):
"""Request to activate a promo code."""
code: str = Field(..., min_length=1, max_length=50, description='Promo code to activate')
class PromocodeActivateResponse(BaseModel):
"""Response after activating a promo code."""
success: bool
message: str
balance_before: float = 0
balance_after: float = 0
bonus_description: str | None = None
class PromocodeDeactivateResponse(BaseModel):
"""Response after deactivating a discount promo code."""
success: bool
message: str
deactivated_code: str | None = None
discount_percent: int = 0
@router.post('/activate', response_model=PromocodeActivateResponse)
async def activate_promocode(
request: PromocodeActivateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Activate a promo code for the current user."""
promocode_service = PromoCodeService()
result = await promocode_service.activate_promocode(db=db, user_id=user.id, code=request.code.strip())
if result['success']:
balance_before_rubles = result.get('balance_before_kopeks', 0) / 100
balance_after_rubles = result.get('balance_after_kopeks', 0) / 100
return PromocodeActivateResponse(
success=True,
message='Promo code activated successfully',
balance_before=balance_before_rubles,
balance_after=balance_after_rubles,
bonus_description=result.get('description'),
)
# Map error codes to messages
error_messages = {
'not_found': 'Promo code not found',
'expired': 'Promo code has expired',
'used': 'Promo code has been fully used',
'already_used_by_user': 'You have already used this promo code',
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
'not_first_purchase': 'This promo code is only available for first purchase',
'user_not_found': 'User not found',
'server_error': 'Server error occurred',
}
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to activate promo code')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
@router.post('/deactivate-discount', response_model=PromocodeDeactivateResponse)
async def deactivate_discount_promocode(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromocodeDeactivateResponse:
"""Deactivate the currently active discount promo code for the current user."""
promocode_service = PromoCodeService()
result = await promocode_service.deactivate_discount_promocode(
db=db,
user_id=user.id,
admin_initiated=False,
)
if result['success']:
return PromocodeDeactivateResponse(
success=True,
message='Discount promo code deactivated successfully',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
)
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'No active discount promo code found',
'discount_already_expired': 'Discount has already expired',
'server_error': 'Server error occurred',
}
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
-197
View File
@@ -1,197 +0,0 @@
"""Referral program routes for cabinet."""
import logging
import math
from fastapi import APIRouter, Depends, Query
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import ReferralEarning, User
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.referral import (
ReferralEarningResponse,
ReferralEarningsListResponse,
ReferralInfoResponse,
ReferralItemResponse,
ReferralListResponse,
ReferralTermsResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/referral', tags=['Cabinet Referral'])
@router.get('', response_model=ReferralInfoResponse)
async def get_referral_info(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get referral program info for current user."""
# Get total referrals count
total_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
total_result = await db.execute(total_query)
total_referrals = total_result.scalar() or 0
# Get active referrals (with subscription)
active_query = (
select(func.count())
.select_from(User)
.where(User.referred_by_id == user.id)
.where(User.has_had_paid_subscription == True)
)
active_result = await db.execute(active_query)
active_referrals = active_result.scalar() or 0
# Get total earnings
earnings_query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == user.id
)
earnings_result = await db.execute(earnings_query)
total_earnings = earnings_result.scalar() or 0
# Get user's commission percent
commission_percent = user.referral_commission_percent
if commission_percent is None:
commission_percent = settings.REFERRAL_COMMISSION_PERCENT
# Build referral link
bot_username = settings.get_bot_username() or 'bot'
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
return ReferralInfoResponse(
referral_code=user.referral_code or '',
referral_link=referral_link,
total_referrals=total_referrals,
active_referrals=active_referrals,
total_earnings_kopeks=total_earnings,
total_earnings_rubles=total_earnings / 100,
commission_percent=commission_percent,
)
@router.get('/list', response_model=ReferralListResponse)
async def get_referral_list(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get list of invited users."""
# Base query with eager loading of subscription relationship
query = select(User).options(selectinload(User.subscription)).where(User.referred_by_id == user.id)
# Get total count
count_query = select(func.count()).select_from(User).where(User.referred_by_id == user.id)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
offset = (page - 1) * per_page
query = query.order_by(desc(User.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
referrals = result.scalars().all()
items = [
ReferralItemResponse(
id=r.id,
username=r.username,
first_name=r.first_name,
created_at=r.created_at,
has_subscription=r.subscription is not None,
has_paid=r.has_had_paid_subscription,
)
for r in referrals
]
pages = math.ceil(total / per_page) if total > 0 else 1
return ReferralListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.get('/earnings', response_model=ReferralEarningsListResponse)
async def get_referral_earnings(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get referral earnings history."""
# Base query
query = select(ReferralEarning).where(ReferralEarning.user_id == user.id)
# Get total count and sum
count_query = select(func.count()).select_from(ReferralEarning).where(ReferralEarning.user_id == user.id)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
sum_query = select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == user.id
)
sum_result = await db.execute(sum_query)
total_amount = sum_result.scalar() or 0
# Paginate
offset = (page - 1) * per_page
query = query.order_by(desc(ReferralEarning.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
earnings = result.scalars().all()
items = []
for e in earnings:
# Get referral user info
referral_query = select(User).where(User.id == e.referral_id)
referral_result = await db.execute(referral_query)
referral_user = referral_result.scalar_one_or_none()
items.append(
ReferralEarningResponse(
id=e.id,
amount_kopeks=e.amount_kopeks,
amount_rubles=e.amount_kopeks / 100,
reason=e.reason or 'Referral commission',
referral_username=referral_user.username if referral_user else None,
referral_first_name=referral_user.first_name if referral_user else None,
created_at=e.created_at,
)
)
pages = math.ceil(total / per_page) if total > 0 else 1
return ReferralEarningsListResponse(
items=items,
total=total,
total_amount_kopeks=total_amount,
total_amount_rubles=total_amount / 100,
page=page,
per_page=per_page,
pages=pages,
)
@router.get('/terms', response_model=ReferralTermsResponse)
async def get_referral_terms():
"""Get referral program terms."""
return ReferralTermsResponse(
is_enabled=settings.is_referral_program_enabled(),
commission_percent=settings.REFERRAL_COMMISSION_PERCENT,
minimum_topup_kopeks=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS,
minimum_topup_rubles=settings.REFERRAL_MINIMUM_TOPUP_KOPEKS / 100,
first_topup_bonus_kopeks=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS,
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
)
File diff suppressed because it is too large Load Diff
-204
View File
@@ -1,204 +0,0 @@
"""Ticket notifications routes for cabinet."""
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import User
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/tickets/notifications', tags=['Cabinet Ticket Notifications'])
admin_router = APIRouter(prefix='/admin/tickets/notifications', tags=['Cabinet Admin Ticket Notifications'])
# Schemas
class TicketNotificationResponse(BaseModel):
"""Single ticket notification."""
id: int
ticket_id: int
notification_type: str
message: str | None = None
is_read: bool
created_at: datetime
read_at: datetime | None = None
class Config:
from_attributes = True
class TicketNotificationListResponse(BaseModel):
"""List of ticket notifications."""
items: list[TicketNotificationResponse]
unread_count: int
class UnreadCountResponse(BaseModel):
"""Unread notifications count."""
unread_count: int
# User endpoints
@router.get('', response_model=TicketNotificationListResponse)
async def get_user_notifications(
unread_only: bool = Query(False, description='Only return unread notifications'),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket notifications for current user."""
notifications = await TicketNotificationCRUD.get_user_notifications(
db, user.id, unread_only=unread_only, limit=limit, offset=offset
)
unread_count = await TicketNotificationCRUD.count_unread_user(db, user.id)
return TicketNotificationListResponse(
items=[TicketNotificationResponse.model_validate(n) for n in notifications],
unread_count=unread_count,
)
@router.get('/unread-count', response_model=UnreadCountResponse)
async def get_user_unread_count(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get unread notifications count for current user."""
count = await TicketNotificationCRUD.count_unread_user(db, user.id)
return UnreadCountResponse(unread_count=count)
@router.post('/{notification_id}/read')
async def mark_notification_as_read(
notification_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a notification as read."""
# Security: Verify notification belongs to current user and is not an admin notification
notification = await TicketNotificationCRUD.get_by_id(db, notification_id)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Notification not found',
)
# Check ownership: notification must belong to user and not be an admin notification
if notification.user_id != user.id or notification.is_for_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to mark this notification as read",
)
await TicketNotificationCRUD.mark_as_read(db, notification_id)
return {'success': True}
@router.post('/read-all')
async def mark_all_notifications_as_read(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all notifications as read for current user."""
count = await TicketNotificationCRUD.mark_all_as_read_user(db, user.id)
return {'success': True, 'marked_count': count}
@router.post('/ticket/{ticket_id}/read')
async def mark_ticket_notifications_as_read(
ticket_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all notifications for a specific ticket as read."""
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(db, ticket_id, user.id, is_admin=False)
return {'success': True, 'marked_count': count}
# Admin endpoints
@admin_router.get('', response_model=TicketNotificationListResponse)
async def get_admin_notifications(
unread_only: bool = Query(False, description='Only return unread notifications'),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket notifications for admins."""
notifications = await TicketNotificationCRUD.get_admin_notifications(
db, unread_only=unread_only, limit=limit, offset=offset
)
unread_count = await TicketNotificationCRUD.count_unread_admin(db)
return TicketNotificationListResponse(
items=[TicketNotificationResponse.model_validate(n) for n in notifications],
unread_count=unread_count,
)
@admin_router.get('/unread-count', response_model=UnreadCountResponse)
async def get_admin_unread_count(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get unread notifications count for admins."""
count = await TicketNotificationCRUD.count_unread_admin(db)
return UnreadCountResponse(unread_count=count)
@admin_router.post('/{notification_id}/read')
async def mark_admin_notification_as_read(
notification_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark an admin notification as read."""
# Security: Verify notification exists and is an admin notification
notification = await TicketNotificationCRUD.get_by_id(db, notification_id)
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Notification not found',
)
# Check that this is actually an admin notification
if not notification.is_for_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='This is not an admin notification',
)
await TicketNotificationCRUD.mark_as_read(db, notification_id)
return {'success': True}
@admin_router.post('/read-all')
async def mark_all_admin_notifications_as_read(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications as read."""
count = await TicketNotificationCRUD.mark_all_as_read_admin(db)
return {'success': True, 'marked_count': count}
@admin_router.post('/ticket/{ticket_id}/read')
async def mark_admin_ticket_notifications_as_read(
ticket_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark all admin notifications for a specific ticket as read."""
count = await TicketNotificationCRUD.mark_ticket_notifications_as_read(db, ticket_id, admin.id, is_admin=True)
return {'success': True, 'marked_count': count}
-300
View File
@@ -1,300 +0,0 @@
"""Support tickets routes for cabinet."""
import logging
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.cabinet.routes.websocket import notify_admins_new_ticket, notify_admins_ticket_reply
from app.config import settings
from app.database.crud.ticket_notification import TicketNotificationCRUD
from app.database.models import Ticket, TicketMessage, User
from app.handlers.tickets import notify_admins_about_new_ticket, notify_admins_about_ticket_reply
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.tickets import (
TicketCreateRequest,
TicketDetailResponse,
TicketListResponse,
TicketMessageCreateRequest,
TicketMessageResponse,
TicketResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
def _message_to_response(message: TicketMessage) -> TicketMessageResponse:
"""Convert TicketMessage to response."""
return TicketMessageResponse(
id=message.id,
message_text=message.message_text or '',
is_from_admin=message.is_from_admin,
has_media=bool(message.media_file_id),
media_type=message.media_type,
media_file_id=message.media_file_id,
media_caption=message.media_caption,
created_at=message.created_at,
)
def _ticket_to_response(ticket: Ticket, include_last_message: bool = True) -> TicketResponse:
"""Convert Ticket to response."""
last_message = None
messages_count = len(ticket.messages) if ticket.messages else 0
if include_last_message and ticket.messages:
last_msg = max(ticket.messages, key=lambda m: m.created_at)
last_message = _message_to_response(last_msg)
return TicketResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
messages_count=messages_count,
last_message=last_message,
)
@router.get('', response_model=TicketListResponse)
async def get_tickets(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's support tickets."""
# Check if tickets are enabled
if not settings.is_support_tickets_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Support tickets are disabled',
)
# Base query
query = select(Ticket).where(Ticket.user_id == user.id).options(selectinload(Ticket.messages))
# Filter by status
if status_filter:
query = query.where(Ticket.status == status_filter)
# Get total count
count_query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user.id)
if status_filter:
count_query = count_query.where(Ticket.status == status_filter)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
offset = (page - 1) * per_page
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(per_page)
result = await db.execute(query)
tickets = result.scalars().all()
items = [_ticket_to_response(t) for t in tickets]
pages = math.ceil(total / per_page) if total > 0 else 1
return TicketListResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
@router.post('', response_model=TicketDetailResponse)
async def create_ticket(
request: TicketCreateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a new support ticket."""
# Check if tickets are enabled
if not settings.is_support_tickets_enabled():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Support tickets are disabled',
)
# Create ticket
ticket = Ticket(
user_id=user.id,
title=request.title,
status='open',
priority='normal',
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(ticket)
await db.flush()
# Create initial message with optional media
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.utcnow(),
)
db.add(message)
await db.commit()
# Refresh to get relationships
await db.refresh(ticket, ['messages'])
# Уведомить админов о новом тикете (Telegram)
try:
await notify_admins_about_new_ticket(ticket, db)
except Exception as e:
logger.error(f'Error notifying admins about new ticket from cabinet: {e}')
# Уведомить админов в кабинете
try:
notification = await TicketNotificationCRUD.create_admin_notification_for_new_ticket(db, ticket)
if notification:
# Отправить WebSocket уведомление
await notify_admins_new_ticket(ticket.id, ticket.title, user.id)
except Exception as e:
logger.error(f'Error creating cabinet notification for new ticket: {e}')
messages = [_message_to_response(m) for m in ticket.messages]
return TicketDetailResponse(
id=ticket.id,
title=ticket.title,
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
messages=messages,
)
@router.get('/{ticket_id}', response_model=TicketDetailResponse)
async def get_ticket(
ticket_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket with all messages."""
query = (
select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id).options(selectinload(Ticket.messages))
)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
messages = sorted(ticket.messages or [], key=lambda m: m.created_at)
messages_response = [_message_to_response(m) for m in messages]
return TicketDetailResponse(
id=ticket.id,
title=ticket.title or f'Ticket #{ticket.id}',
status=ticket.status,
priority=ticket.priority or 'normal',
created_at=ticket.created_at,
updated_at=ticket.updated_at or ticket.created_at,
closed_at=ticket.closed_at,
is_reply_blocked=ticket.is_reply_blocked if hasattr(ticket, 'is_reply_blocked') else False,
messages=messages_response,
)
@router.post('/{ticket_id}/messages', response_model=TicketMessageResponse)
async def add_ticket_message(
ticket_id: int,
request: TicketMessageCreateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Add message to existing ticket."""
# Get ticket
query = select(Ticket).where(Ticket.id == ticket_id, Ticket.user_id == user.id)
result = await db.execute(query)
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Ticket not found',
)
# Check if ticket is closed
if ticket.status == 'closed':
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot add message to closed ticket',
)
# Check if replies are blocked
if hasattr(ticket, 'is_reply_blocked') and ticket.is_reply_blocked:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Replies to this ticket are blocked',
)
# Create message with optional media
message = TicketMessage(
ticket_id=ticket.id,
user_id=user.id,
message_text=request.message,
is_from_admin=False,
media_type=request.media_type,
media_file_id=request.media_file_id,
media_caption=request.media_caption,
created_at=datetime.utcnow(),
)
db.add(message)
# Update ticket status and timestamp
if ticket.status == 'answered':
ticket.status = 'pending'
ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(message)
# Уведомить админов об ответе пользователя (Telegram)
try:
await notify_admins_about_ticket_reply(ticket, request.message, db)
except Exception as e:
logger.error(f'Error notifying admins about ticket reply from cabinet: {e}')
# Уведомить админов в кабинете
try:
notification = await TicketNotificationCRUD.create_admin_notification_for_user_reply(
db, ticket, request.message
)
if notification:
# Отправить WebSocket уведомление
await notify_admins_ticket_reply(ticket.id, (request.message or '')[:100], user.id)
except Exception as e:
logger.error(f'Error creating cabinet notification for user reply: {e}')
return _message_to_response(message)
-589
View File
@@ -1,589 +0,0 @@
"""WebSocket endpoint for cabinet real-time notifications."""
from __future__ import annotations
import asyncio
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.cabinet.auth.jwt_handler import get_token_payload
from app.config import settings
from app.database.crud.user import get_user_by_id
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
router = APIRouter()
class CabinetConnectionManager:
"""Менеджер WebSocket подключений для кабинета."""
def __init__(self):
# user_id -> set of websocket connections
self._user_connections: dict[int, set[WebSocket]] = {}
# admin user_ids -> set of websocket connections
self._admin_connections: dict[int, set[WebSocket]] = {}
self._lock = asyncio.Lock()
async def connect(self, websocket: WebSocket, user_id: int, is_admin: bool) -> None:
"""Зарегистрировать подключение."""
async with self._lock:
if user_id not in self._user_connections:
self._user_connections[user_id] = set()
self._user_connections[user_id].add(websocket)
if is_admin:
if user_id not in self._admin_connections:
self._admin_connections[user_id] = set()
self._admin_connections[user_id].add(websocket)
logger.debug(
'Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d',
user_id,
is_admin,
len(self._user_connections),
)
async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
"""Отменить регистрацию подключения."""
async with self._lock:
if user_id in self._user_connections:
self._user_connections[user_id].discard(websocket)
if not self._user_connections[user_id]:
del self._user_connections[user_id]
if user_id in self._admin_connections:
self._admin_connections[user_id].discard(websocket)
if not self._admin_connections[user_id]:
del self._admin_connections[user_id]
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
async def send_to_user(self, user_id: int, message: dict) -> None:
"""Отправить сообщение конкретному пользователю."""
# Snapshot connections under the lock to avoid mutation during iteration
async with self._lock:
connections = list(self._user_connections.get(user_id, set()))
if not connections:
return
disconnected = set()
data = json.dumps(message, default=str, ensure_ascii=False)
for ws in connections:
try:
await ws.send_text(data)
except Exception as e:
logger.warning('Failed to send to user %d: %s', user_id, e)
disconnected.add(ws)
# Cleanup disconnected
if disconnected:
async with self._lock:
for ws in disconnected:
self._user_connections.get(user_id, set()).discard(ws)
async def send_to_admins(self, message: dict) -> None:
"""Отправить сообщение всем админам."""
# Snapshot connections under the lock to avoid mutation during iteration
async with self._lock:
if not self._admin_connections:
return
# Create a snapshot: list of (user_id, list of websockets)
admin_snapshot = [(user_id, list(connections)) for user_id, connections in self._admin_connections.items()]
data = json.dumps(message, default=str, ensure_ascii=False)
disconnected_by_user: dict[int, set[WebSocket]] = {}
for user_id, connections in admin_snapshot:
for ws in connections:
try:
await ws.send_text(data)
except Exception as e:
logger.warning('Failed to send to admin %d: %s', user_id, e)
if user_id not in disconnected_by_user:
disconnected_by_user[user_id] = set()
disconnected_by_user[user_id].add(ws)
# Cleanup disconnected
if disconnected_by_user:
async with self._lock:
for user_id, ws_set in disconnected_by_user.items():
for ws in ws_set:
self._admin_connections.get(user_id, set()).discard(ws)
# Глобальный менеджер подключений
cabinet_ws_manager = CabinetConnectionManager()
async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
"""
Проверить JWT токен для WebSocket.
Returns:
tuple[user_id, is_admin] или (None, False) если токен невалидный
"""
if not token:
return None, False
payload = get_token_payload(token, expected_type='access')
if not payload:
return None, False
try:
user_id = int(payload.get('sub'))
except (TypeError, ValueError):
return None, False
try:
async with AsyncSessionLocal() as db:
user = await get_user_by_id(db, user_id)
if not user or user.status != 'active':
return None, False
is_admin = settings.is_admin(
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
)
return user_id, is_admin
except (TimeoutError, OSError, ConnectionRefusedError) as e:
logger.error('Database connection error in WS token verification: %s', str(e)[:200])
return None, False
@router.websocket('/ws')
async def cabinet_websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint для real-time уведомлений кабинета."""
client_host = websocket.client.host if websocket.client else 'unknown'
# Получаем токен из query params
token = websocket.query_params.get('token')
if not token:
logger.debug('Cabinet WS: No token from %s', client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason='Unauthorized: No token')
return
# Верифицируем токен
user_id, is_admin = await verify_cabinet_ws_token(token)
if not user_id:
logger.debug('Cabinet WS: Invalid token from %s', client_host)
# Принимаем и сразу закрываем с кодом ошибки
await websocket.accept()
await websocket.close(code=1008, reason='Unauthorized: Invalid token')
return
# Принимаем соединение
try:
await websocket.accept()
logger.debug('Cabinet WS accepted: user_id=%d, is_admin=%s', user_id, is_admin)
except Exception as e:
logger.error('Cabinet WS: Failed to accept from %s: %s', client_host, e)
return
# Регистрируем подключение
await cabinet_ws_manager.connect(websocket, user_id, is_admin)
try:
# Приветственное сообщение
await websocket.send_json(
{
'type': 'connected',
'user_id': user_id,
'is_admin': is_admin,
}
)
# Обрабатываем входящие сообщения
while True:
try:
data = await websocket.receive_text()
message = json.loads(data)
# Ping/pong для keepalive
if message.get('type') == 'ping':
await websocket.send_json({'type': 'pong'})
except json.JSONDecodeError:
logger.warning('Cabinet WS: Invalid JSON from user %d', user_id)
except WebSocketDisconnect:
break
except Exception as e:
logger.exception('Cabinet WS error for user %d: %s', user_id, e)
break
except WebSocketDisconnect:
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
except Exception as e:
logger.exception('Cabinet WS error: %s', e)
finally:
await cabinet_ws_manager.disconnect(websocket, user_id)
# Функции для отправки уведомлений (используются из других модулей)
async def notify_user_ticket_reply(user_id: int, ticket_id: int, message: str) -> None:
"""Уведомить пользователя об ответе в тикете."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'ticket.admin_reply',
'ticket_id': ticket_id,
'message': message,
},
)
async def notify_admins_new_ticket(ticket_id: int, title: str, user_id: int) -> None:
"""Уведомить админов о новом тикете."""
await cabinet_ws_manager.send_to_admins(
{
'type': 'ticket.new',
'ticket_id': ticket_id,
'title': title,
'user_id': user_id,
}
)
async def notify_admins_ticket_reply(ticket_id: int, message: str, user_id: int) -> None:
"""Уведомить админов об ответе пользователя."""
await cabinet_ws_manager.send_to_admins(
{
'type': 'ticket.user_reply',
'ticket_id': ticket_id,
'message': message,
'user_id': user_id,
}
)
# ============================================================================
# Уведомления о балансе
# ============================================================================
async def notify_user_balance_topup(
user_id: int,
amount_kopeks: int,
new_balance_kopeks: int,
description: str = '',
) -> None:
"""Уведомить пользователя о пополнении баланса."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'balance.topup',
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
'new_balance_kopeks': new_balance_kopeks,
'new_balance_rubles': new_balance_kopeks / 100,
'description': description,
},
)
async def notify_user_balance_change(
user_id: int,
amount_kopeks: int,
new_balance_kopeks: int,
description: str = '',
) -> None:
"""Уведомить пользователя об изменении баланса."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'balance.change',
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
'new_balance_kopeks': new_balance_kopeks,
'new_balance_rubles': new_balance_kopeks / 100,
'description': description,
},
)
# ============================================================================
# Уведомления о подписке
# ============================================================================
async def notify_user_subscription_activated(
user_id: int,
expires_at: str,
tariff_name: str = '',
) -> None:
"""Уведомить пользователя об активации подписки."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.activated',
'expires_at': expires_at,
'tariff_name': tariff_name,
},
)
async def notify_user_subscription_expiring(
user_id: int,
days_left: int,
expires_at: str,
) -> None:
"""Уведомить пользователя о скором истечении подписки."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.expiring',
'days_left': days_left,
'expires_at': expires_at,
},
)
async def notify_user_subscription_expired(user_id: int) -> None:
"""Уведомить пользователя об истечении подписки."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.expired',
},
)
async def notify_user_subscription_renewed(
user_id: int,
new_expires_at: str,
amount_kopeks: int = 0,
) -> None:
"""Уведомить пользователя о продлении подписки."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.renewed',
'new_expires_at': new_expires_at,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
},
)
async def notify_user_devices_purchased(
user_id: int,
devices_added: int,
new_device_limit: int,
amount_kopeks: int,
) -> None:
"""Уведомить пользователя о покупке устройств."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.devices_purchased',
'devices_added': devices_added,
'new_device_limit': new_device_limit,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
},
)
async def notify_user_traffic_purchased(
user_id: int,
traffic_gb_added: int,
new_traffic_limit_gb: int,
amount_kopeks: int,
) -> None:
"""Уведомить пользователя о покупке трафика."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.traffic_purchased',
'traffic_gb_added': traffic_gb_added,
'new_traffic_limit_gb': new_traffic_limit_gb,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
},
)
# ============================================================================
# Уведомления об автопродлении
# ============================================================================
async def notify_user_autopay_success(
user_id: int,
amount_kopeks: int,
new_expires_at: str,
) -> None:
"""Уведомить пользователя об успешном автопродлении."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'autopay.success',
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
'new_expires_at': new_expires_at,
},
)
async def notify_user_autopay_failed(
user_id: int,
reason: str = '',
) -> None:
"""Уведомить пользователя о неудачном автопродлении."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'autopay.failed',
'reason': reason,
},
)
async def notify_user_autopay_insufficient_funds(
user_id: int,
required_kopeks: int,
balance_kopeks: int,
) -> None:
"""Уведомить о недостатке средств для автопродления."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'autopay.insufficient_funds',
'required_kopeks': required_kopeks,
'required_rubles': required_kopeks / 100,
'balance_kopeks': balance_kopeks,
'balance_rubles': balance_kopeks / 100,
},
)
# ============================================================================
# Уведомления о бане/разбане
# ============================================================================
async def notify_user_ban(user_id: int, reason: str = '') -> None:
"""Уведомить пользователя о блокировке."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'account.banned',
'reason': reason,
},
)
async def notify_user_unban(user_id: int) -> None:
"""Уведомить пользователя о разблокировке."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'account.unbanned',
},
)
async def notify_user_warning(user_id: int, message: str) -> None:
"""Уведомить пользователя о предупреждении."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'account.warning',
'message': message,
},
)
# ============================================================================
# Уведомления о рефералах
# ============================================================================
async def notify_user_referral_bonus(
user_id: int,
bonus_kopeks: int,
referral_name: str = '',
) -> None:
"""Уведомить пользователя о реферальном бонусе."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'referral.bonus',
'bonus_kopeks': bonus_kopeks,
'bonus_rubles': bonus_kopeks / 100,
'referral_name': referral_name,
},
)
async def notify_user_referral_registered(
user_id: int,
referral_name: str = '',
) -> None:
"""Уведомить пользователя о регистрации нового реферала."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'referral.registered',
'referral_name': referral_name,
},
)
# ============================================================================
# Прочие уведомления
# ============================================================================
async def notify_user_daily_debit(
user_id: int,
amount_kopeks: int,
new_balance_kopeks: int,
) -> None:
"""Уведомить о ежедневном списании."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.daily_debit',
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
'new_balance_kopeks': new_balance_kopeks,
'new_balance_rubles': new_balance_kopeks / 100,
},
)
async def notify_user_traffic_reset(user_id: int) -> None:
"""Уведомить о сбросе трафика."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.traffic_reset',
},
)
async def notify_user_payment_received(
user_id: int,
amount_kopeks: int,
payment_method: str = '',
) -> None:
"""Уведомить о полученном платеже."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'payment.received',
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
'payment_method': payment_method,
},
)
-275
View File
@@ -1,275 +0,0 @@
"""
API роуты колеса удачи для пользователей.
"""
import logging
import math
import time
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.cabinet.dependencies import get_cabinet_db, get_current_cabinet_user
from app.cabinet.schemas.wheel import (
SpinAvailabilityResponse,
SpinHistoryItem,
SpinHistoryResponse,
SpinRequest,
SpinResultResponse,
WheelConfigResponse,
WheelPrizeDisplay,
)
from app.config import settings
from app.database.crud.wheel import (
get_or_create_wheel_config,
get_user_spin_history,
get_user_spins_today,
get_wheel_prizes,
)
from app.database.models import User
from app.services.wheel_service import wheel_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/wheel', tags=['Fortune Wheel'])
@router.get('/config', response_model=WheelConfigResponse)
async def get_wheel_config(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить конфигурацию колеса удачи."""
config = await get_or_create_wheel_config(db)
prizes = await get_wheel_prizes(db, config.id, active_only=True)
spins_today = await get_user_spins_today(db, user.id)
# Проверяем доступность
availability = await wheel_service.check_availability(db, user)
prizes_display = [
WheelPrizeDisplay(
id=p.id,
display_name=p.display_name,
emoji=p.emoji,
color=p.color,
prize_type=p.prize_type,
)
for p in prizes
]
return WheelConfigResponse(
is_enabled=config.is_enabled,
name=config.name,
spin_cost_stars=config.spin_cost_stars if config.spin_cost_stars_enabled else None,
spin_cost_days=config.spin_cost_days if config.spin_cost_days_enabled else None,
spin_cost_stars_enabled=config.spin_cost_stars_enabled,
spin_cost_days_enabled=config.spin_cost_days_enabled,
prizes=prizes_display,
daily_limit=config.daily_spin_limit,
user_spins_today=spins_today,
can_spin=availability.can_spin,
can_spin_reason=availability.reason,
can_pay_stars=availability.can_pay_stars,
can_pay_days=availability.can_pay_days,
user_balance_kopeks=availability.user_balance_kopeks,
required_balance_kopeks=availability.required_balance_kopeks,
)
@router.get('/availability', response_model=SpinAvailabilityResponse)
async def check_spin_availability(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Проверить доступность спина."""
availability = await wheel_service.check_availability(db, user)
return SpinAvailabilityResponse(
can_spin=availability.can_spin,
reason=availability.reason,
spins_remaining_today=availability.spins_remaining_today,
can_pay_stars=availability.can_pay_stars,
can_pay_days=availability.can_pay_days,
min_subscription_days=availability.min_subscription_days,
user_subscription_days=availability.user_subscription_days,
user_balance_kopeks=availability.user_balance_kopeks,
required_balance_kopeks=availability.required_balance_kopeks,
)
@router.post('/spin', response_model=SpinResultResponse)
async def spin_wheel(
request: SpinRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Крутить колесо удачи."""
result = await wheel_service.spin(db, user, request.payment_type.value)
if not result.success:
# Возвращаем ошибку в теле ответа, а не HTTP exception
return SpinResultResponse(
success=False,
error=result.error,
message=result.message,
)
return SpinResultResponse(
success=True,
prize_id=result.prize_id,
prize_type=result.prize_type,
prize_value=result.prize_value,
prize_display_name=result.prize_display_name,
emoji=result.emoji,
color=result.color,
rotation_degrees=result.rotation_degrees,
message=result.message,
promocode=result.promocode,
)
@router.get('/history', response_model=SpinHistoryResponse)
async def get_spin_history(
page: int = 1,
per_page: int = 20,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Получить историю спинов пользователя."""
page = max(page, 1)
if per_page < 1 or per_page > 100:
per_page = 20
offset = (page - 1) * per_page
spins, total = await get_user_spin_history(db, user.id, limit=per_page, offset=offset)
items = []
for spin in spins:
# Получаем emoji и color из приза, если он есть
emoji = '🎁'
color = '#3B82F6'
if spin.prize:
emoji = spin.prize.emoji
color = spin.prize.color
items.append(
SpinHistoryItem(
id=spin.id,
payment_type=spin.payment_type,
payment_amount=spin.payment_amount,
prize_type=spin.prize_type,
prize_value=spin.prize_value,
prize_display_name=spin.prize_display_name,
emoji=emoji,
color=color,
prize_value_kopeks=spin.prize_value_kopeks,
created_at=spin.created_at,
)
)
pages = math.ceil(total / per_page) if total > 0 else 1
return SpinHistoryResponse(
items=items,
total=total,
page=page,
per_page=per_page,
pages=pages,
)
class StarsInvoiceResponse(BaseModel):
"""Ответ с ссылкой на Stars invoice."""
invoice_url: str
stars_amount: int
@router.post('/stars-invoice', response_model=StarsInvoiceResponse)
async def create_stars_invoice(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Создать Telegram Stars invoice для оплаты спина колеса.
Используется в Telegram Mini App для прямой оплаты Stars.
"""
config = await get_or_create_wheel_config(db)
if not config.is_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Колесо удачи недоступно',
)
if not config.spin_cost_stars_enabled or not config.spin_cost_stars:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Оплата Stars не включена',
)
# Проверяем лимит спинов
spins_today = await get_user_spins_today(db, user.id)
if config.daily_spin_limit > 0 and spins_today >= config.daily_spin_limit:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Достигнут дневной лимит спинов',
)
# Проверяем наличие призов
prizes = await get_wheel_prizes(db, config.id, active_only=True)
if not prizes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Призы не настроены',
)
stars_amount = config.spin_cost_stars
payload = f'wheel_spin_{user.id}_{int(time.time())}'
# Создаем invoice через Telegram Bot API
try:
bot_token = settings.BOT_TOKEN
api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink'
async with httpx.AsyncClient() as client:
response = await client.post(
api_url,
json={
'title': 'Колесо удачи',
'description': f'Спин колеса удачи ({stars_amount} ⭐)',
'payload': payload,
'provider_token': '', # Пустой для Stars
'currency': 'XTR',
'prices': [{'label': 'Спин колеса', 'amount': stars_amount}],
},
)
result = response.json()
if not result.get('ok'):
logger.error(f'Telegram API error: {result}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка создания инвойса',
)
invoice_url = result['result']
logger.info(f'Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}')
return StarsInvoiceResponse(
invoice_url=invoice_url,
stars_amount=stars_amount,
)
except httpx.HTTPError as e:
logger.error(f'HTTP error creating invoice: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Ошибка соединения с Telegram',
)
-89
View File
@@ -1,89 +0,0 @@
"""Cabinet Pydantic schemas."""
from .auth import (
AuthResponse,
EmailLoginRequest,
EmailRegisterRequest,
EmailVerifyRequest,
PasswordForgotRequest,
PasswordResetRequest,
RefreshTokenRequest,
TelegramAuthRequest,
TelegramWidgetAuthRequest,
TokenResponse,
UserResponse,
)
from .balance import (
BalanceResponse,
PaymentMethodResponse,
TopUpRequest,
TopUpResponse,
TransactionListResponse,
TransactionResponse,
)
from .referral import (
ReferralEarningResponse,
ReferralInfoResponse,
ReferralListResponse,
ReferralTermsResponse,
)
from .subscription import (
AutopayUpdateRequest,
DevicePurchaseRequest,
RenewalOptionResponse,
RenewalRequest,
SubscriptionResponse,
TrafficPackageResponse,
TrafficPurchaseInfo,
TrafficPurchaseRequest,
)
from .tickets import (
TicketCreateRequest,
TicketListResponse,
TicketMessageCreateRequest,
TicketMessageResponse,
TicketResponse,
)
__all__ = [
'AuthResponse',
'AutopayUpdateRequest',
# Balance
'BalanceResponse',
'DevicePurchaseRequest',
'EmailLoginRequest',
'EmailRegisterRequest',
'EmailVerifyRequest',
'PasswordForgotRequest',
'PasswordResetRequest',
'PaymentMethodResponse',
'ReferralEarningResponse',
# Referral
'ReferralInfoResponse',
'ReferralListResponse',
'ReferralTermsResponse',
'RefreshTokenRequest',
'RenewalOptionResponse',
'RenewalRequest',
# Subscription
'SubscriptionResponse',
# Auth
'TelegramAuthRequest',
'TelegramWidgetAuthRequest',
'TicketCreateRequest',
'TicketListResponse',
'TicketMessageCreateRequest',
'TicketMessageResponse',
# Tickets
'TicketResponse',
'TokenResponse',
'TopUpRequest',
'TopUpResponse',
'TrafficPackageResponse',
'TrafficPurchaseInfo',
'TrafficPurchaseRequest',
'TransactionListResponse',
'TransactionResponse',
'UserResponse',
]
-140
View File
@@ -1,140 +0,0 @@
"""Authentication schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class TelegramAuthRequest(BaseModel):
"""Request for Telegram WebApp initData authentication."""
init_data: str = Field(..., description='Telegram WebApp initData string')
class TelegramWidgetAuthRequest(BaseModel):
"""Request for Telegram Login Widget authentication."""
id: int = Field(..., description='Telegram user ID')
first_name: str = Field(..., description="User's first name")
last_name: str | None = Field(None, description="User's last name")
username: str | None = Field(None, description="User's username")
photo_url: str | None = Field(None, description="User's photo URL")
auth_date: int = Field(..., description='Unix timestamp of authentication')
hash: str = Field(..., description='Authentication hash')
class EmailRegisterRequest(BaseModel):
"""Request to register/link email to existing Telegram account."""
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., min_length=8, max_length=128, description='Password (min 8 chars)')
class EmailVerifyRequest(BaseModel):
"""Request to verify email with token."""
token: str = Field(..., description='Email verification token')
class EmailLoginRequest(BaseModel):
"""Request to login with email and password."""
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., description='Password')
class RefreshTokenRequest(BaseModel):
"""Request to refresh access token."""
refresh_token: str = Field(..., description='Refresh token')
class PasswordForgotRequest(BaseModel):
"""Request to initiate password reset."""
email: EmailStr = Field(..., description='Email address')
class PasswordResetRequest(BaseModel):
"""Request to reset password with token."""
token: str = Field(..., description='Password reset token')
password: str = Field(..., min_length=8, max_length=128, description='New password (min 8 chars)')
class TokenResponse(BaseModel):
"""Token pair response."""
access_token: str
refresh_token: str
token_type: str = 'bearer'
expires_in: int = Field(..., description='Access token expiration in seconds')
class UserResponse(BaseModel):
"""User data response."""
id: int
telegram_id: int | None = None # Nullable для email-only пользователей
username: str | None = None
first_name: str | None = None
last_name: str | None = None
email: str | None = None
email_verified: bool = False
balance_kopeks: int = 0
balance_rubles: float = 0.0
referral_code: str | None = None
language: str = 'ru'
created_at: datetime
auth_type: str = 'telegram' # "telegram" или "email"
class Config:
from_attributes = True
class EmailRegisterStandaloneRequest(BaseModel):
"""Request to register new account with email (no Telegram required)."""
email: EmailStr = Field(..., description='Email address')
password: str = Field(..., min_length=8, max_length=128, description='Password (min 8 chars)')
first_name: str | None = Field(None, max_length=64, description='First name')
language: str = Field('ru', description='Preferred language')
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
class AuthResponse(BaseModel):
"""Full authentication response with tokens and user."""
access_token: str
refresh_token: str
token_type: str = 'bearer'
expires_in: int
user: UserResponse
class RegisterResponse(BaseModel):
"""Response for email registration (before verification)."""
message: str = Field(..., description='Success message')
email: str = Field(..., description='Email address to verify')
requires_verification: bool = Field(True, description='Whether email verification is required')
class EmailChangeRequest(BaseModel):
"""Request to initiate email change."""
new_email: EmailStr = Field(..., description='New email address')
class EmailChangeVerifyRequest(BaseModel):
"""Request to verify email change with code."""
code: str = Field(..., min_length=6, max_length=6, description='6-digit verification code')
class EmailChangeResponse(BaseModel):
"""Response for email change initiation."""
message: str = Field(..., description='Success message')
new_email: str = Field(..., description='New email address pending verification')
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
-139
View File
@@ -1,139 +0,0 @@
"""Balance and payment schemas for cabinet."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class BalanceResponse(BaseModel):
"""User balance data."""
balance_kopeks: int
balance_rubles: float
class TransactionResponse(BaseModel):
"""Transaction history item."""
id: int
type: str
amount_kopeks: int
amount_rubles: float
description: str | None = None
payment_method: str | None = None
is_completed: bool
created_at: datetime
completed_at: datetime | None = None
class Config:
from_attributes = True
class TransactionListResponse(BaseModel):
"""Paginated transaction list."""
items: list[TransactionResponse]
total: int
page: int
per_page: int
pages: int
class PaymentOptionResponse(BaseModel):
"""Payment method option (e.g. Platega sub-methods)."""
id: str
name: str
description: str | None = None
class PaymentMethodResponse(BaseModel):
"""Available payment method."""
id: str
name: str
description: str | None = None
min_amount_kopeks: int
max_amount_kopeks: int
is_available: bool = True
options: list[dict[str, Any]] | None = None
class TopUpRequest(BaseModel):
"""Request to create payment for balance top-up."""
amount_kopeks: int = Field(..., ge=1000, description='Amount in kopeks (min 10 rubles)')
payment_method: str = Field(..., description='Payment method ID')
payment_option: str | None = Field(None, description='Payment option (e.g. Platega method code)')
class TopUpResponse(BaseModel):
"""Response with payment info."""
payment_id: str
payment_url: str
amount_kopeks: int
amount_rubles: float
status: str
expires_at: datetime | None = None
class StarsInvoiceRequest(BaseModel):
"""Request to create Telegram Stars invoice for balance top-up."""
amount_kopeks: int = Field(..., ge=100, description='Amount in kopeks (min 1 ruble)')
class StarsInvoiceResponse(BaseModel):
"""Response with Telegram Stars invoice link."""
invoice_url: str
stars_amount: int
amount_kopeks: int
class PendingPaymentResponse(BaseModel):
"""Pending payment details for manual verification."""
id: int
method: str
method_display: str
identifier: str
amount_kopeks: int
amount_rubles: float
status: str
status_emoji: str
status_text: str
is_paid: bool
is_checkable: bool
created_at: datetime
expires_at: datetime | None = None
payment_url: str | None = None
user_id: int | None = None
user_telegram_id: int | None = None
user_username: str | None = None
class Config:
from_attributes = True
class PendingPaymentListResponse(BaseModel):
"""Paginated list of pending payments."""
items: list[PendingPaymentResponse]
total: int
page: int
per_page: int
pages: int
class ManualCheckResponse(BaseModel):
"""Response after manual payment status check."""
success: bool
message: str
payment: PendingPaymentResponse | None = None
status_changed: bool = False
old_status: str | None = None
new_status: str | None = None
-386
View File
@@ -1,386 +0,0 @@
"""Schemas for Ban System integration in cabinet."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# === Status ===
class BanSystemStatusResponse(BaseModel):
"""Ban System integration status."""
enabled: bool
configured: bool
# === Stats ===
class BanSystemStatsResponse(BaseModel):
"""Overall Ban System statistics."""
total_users: int = 0
active_users: int = 0
users_over_limit: int = 0
total_requests: int = 0
total_punishments: int = 0
active_punishments: int = 0
nodes_online: int = 0
nodes_total: int = 0
agents_online: int = 0
agents_total: int = 0
panel_connected: bool = False
uptime_seconds: int | None = None
# === Users ===
class BanUserIPInfo(BaseModel):
"""User IP address information."""
ip: str
first_seen: datetime | None = None
last_seen: datetime | None = None
node: str | None = None
request_count: int = 0
country_code: str | None = None
country_name: str | None = None
city: str | None = None
class BanUserRequestLog(BaseModel):
"""User request log entry."""
timestamp: datetime
source_ip: str
destination: str | None = None
dest_port: int | None = None
protocol: str | None = None
action: str | None = None
node: str | None = None
class BanUserListItem(BaseModel):
"""User in the list."""
email: str
unique_ip_count: int = 0
total_requests: int = 0
limit: int | None = None
is_over_limit: bool = False
blocked_count: int = 0
last_seen: datetime | None = None
class BanUsersListResponse(BaseModel):
"""Paginated list of users."""
users: list[BanUserListItem] = []
total: int = 0
offset: int = 0
limit: int = 50
class BanUserDetailResponse(BaseModel):
"""Detailed user information."""
email: str
unique_ip_count: int = 0
total_requests: int = 0
limit: int | None = None
is_over_limit: bool = False
blocked_count: int = 0
ips: list[BanUserIPInfo] = []
recent_requests: list[BanUserRequestLog] = []
network_type: str | None = None # wifi, mobile, mixed
# === Punishments (Bans) ===
class BanPunishmentItem(BaseModel):
"""Punishment/ban entry."""
id: int | None = None
user_id: str
uuid: str | None = None
username: str
reason: str | None = None
punished_at: datetime
enable_at: datetime | None = None
ip_count: int = 0
limit: int = 0
enabled: bool = False
enabled_at: datetime | None = None
node_name: str | None = None
class BanPunishmentsListResponse(BaseModel):
"""List of active punishments."""
punishments: list[BanPunishmentItem] = []
total: int = 0
class BanHistoryResponse(BaseModel):
"""Punishment history."""
items: list[BanPunishmentItem] = []
total: int = 0
class BanUserRequest(BaseModel):
"""Request to ban a user."""
username: str = Field(..., min_length=1)
minutes: int = Field(default=30, ge=1)
reason: str | None = Field(None, max_length=500)
class UnbanResponse(BaseModel):
"""Unban response."""
success: bool
message: str
# === Nodes ===
class BanNodeItem(BaseModel):
"""Node information."""
name: str
address: str | None = None
is_connected: bool = False
last_seen: datetime | None = None
users_count: int = 0
agent_stats: dict[str, Any] | None = None
class BanNodesListResponse(BaseModel):
"""List of nodes."""
nodes: list[BanNodeItem] = []
total: int = 0
online: int = 0
# === Agents ===
class BanAgentItem(BaseModel):
"""Monitoring agent information."""
node_name: str
sent_total: int = 0
dropped_total: int = 0
batches_total: int = 0
reconnects: int = 0
failures: int = 0
queue_size: int = 0
queue_max: int = 0
dedup_checked: int = 0
dedup_skipped: int = 0
filter_checked: int = 0
filter_filtered: int = 0
health: str = 'unknown' # healthy, warning, critical
is_online: bool = False
last_report: datetime | None = None
class BanAgentsSummary(BaseModel):
"""Agents summary statistics."""
total_agents: int = 0
online_agents: int = 0
total_sent: int = 0
total_dropped: int = 0
avg_queue_size: float = 0.0
healthy_count: int = 0
warning_count: int = 0
critical_count: int = 0
class BanAgentsListResponse(BaseModel):
"""List of agents."""
agents: list[BanAgentItem] = []
summary: BanAgentsSummary | None = None
total: int = 0
online: int = 0
# === Traffic ===
class BanTrafficStats(BaseModel):
"""Traffic statistics."""
total_bytes: int = 0
upload_bytes: int = 0
download_bytes: int = 0
total_users: int = 0
violators_count: int = 0
class BanTrafficUserItem(BaseModel):
"""User traffic information."""
username: str
email: str | None = None
total_bytes: int = 0
upload_bytes: int = 0
download_bytes: int = 0
limit_bytes: int | None = None
is_over_limit: bool = False
class BanTrafficViolationItem(BaseModel):
"""Traffic limit violation entry."""
id: int | None = None
username: str
email: str | None = None
violation_type: str
description: str | None = None
bytes_used: int = 0
bytes_limit: int = 0
detected_at: datetime
resolved: bool = False
class BanTrafficViolationsResponse(BaseModel):
"""List of traffic violations."""
violations: list[BanTrafficViolationItem] = []
total: int = 0
class BanTrafficTopItem(BaseModel):
"""Top user by traffic."""
username: str
bytes_total: int = 0
bytes_limit: int | None = None
over_limit: bool = False
class BanTrafficResponse(BaseModel):
"""Full traffic statistics response."""
enabled: bool = False
stats: dict[str, Any] | None = None
top_users: list[BanTrafficTopItem] = []
recent_violations: list[BanTrafficViolationItem] = []
# === Settings ===
class BanSettingDefinition(BaseModel):
"""Setting definition with value."""
key: str
value: Any
type: str # bool, int, str, list
min_value: int | None = None
max_value: int | None = None
editable: bool = True
description: str | None = None
category: str | None = None
class BanSettingsResponse(BaseModel):
"""All settings response."""
settings: list[BanSettingDefinition] = []
class BanSettingUpdateRequest(BaseModel):
"""Request to update a setting."""
value: Any
class BanWhitelistRequest(BaseModel):
"""Request to add/remove from whitelist."""
username: str = Field(..., min_length=1)
# === Reports ===
class BanReportTopViolator(BaseModel):
"""Top violator in report."""
username: str
count: int = 0
class BanReportResponse(BaseModel):
"""Period report response."""
period_hours: int = 24
current_users: int = 0
current_ips: int = 0
punishment_stats: dict[str, Any] | None = None
top_violators: list[BanReportTopViolator] = []
# === Health ===
class BanHealthComponent(BaseModel):
"""Health component status."""
name: str
status: str # healthy, degraded, unhealthy
message: str | None = None
details: dict[str, Any] | None = None
class BanHealthResponse(BaseModel):
"""Health status response."""
status: str # healthy, degraded, unhealthy
uptime: int | None = None
components: list[BanHealthComponent] = []
class BanHealthDetailedResponse(BaseModel):
"""Detailed health response."""
status: str
uptime: int | None = None
components: dict[str, Any] = {}
# === Agent History ===
class BanAgentHistoryItem(BaseModel):
"""Agent history item."""
timestamp: datetime
sent_total: int = 0
dropped_total: int = 0
queue_size: int = 0
batches_total: int = 0
class BanAgentHistoryResponse(BaseModel):
"""Agent history response."""
node: str
hours: int = 24
records: int = 0
delta: dict[str, Any] | None = None
first: dict[str, Any] | None = None
last: dict[str, Any] | None = None
history: list[BanAgentHistoryItem] = []
-209
View File
@@ -1,209 +0,0 @@
"""Pydantic schemas for cabinet broadcasts."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
# ============ Channel Types ============
BroadcastChannel = Literal['telegram', 'email', 'both']
# ============ Filters ============
class BroadcastFilter(BaseModel):
"""Single broadcast filter."""
key: str
label: str
count: int | None = None
group: str | None = None # basic, subscription, traffic, registration, source, activity
class TariffFilter(BaseModel):
"""Tariff-based filter."""
key: str # tariff_1, tariff_2, ...
label: str # tariff name
tariff_id: int
count: int
class BroadcastFiltersResponse(BaseModel):
"""Response with all available filters."""
filters: list[BroadcastFilter] # basic filters
tariff_filters: list[TariffFilter] # tariff filters
custom_filters: list[BroadcastFilter] # custom filters
# ============ Tariffs ============
class TariffForBroadcast(BaseModel):
"""Tariff info for broadcast filtering."""
id: int
name: str
filter_key: str # tariff_{id}
active_users_count: int
class BroadcastTariffsResponse(BaseModel):
"""Response with tariffs for filtering."""
tariffs: list[TariffForBroadcast]
# ============ Buttons ============
class BroadcastButton(BaseModel):
"""Single broadcast button."""
key: str
label: str
default: bool = False
class BroadcastButtonsResponse(BaseModel):
"""Response with available buttons."""
buttons: list[BroadcastButton]
# ============ Media ============
class BroadcastMediaRequest(BaseModel):
"""Media attachment for broadcast."""
type: str = Field(..., pattern=r'^(photo|video|document)$')
file_id: str
caption: str | None = None
# ============ Create ============
class BroadcastCreateRequest(BaseModel):
"""Request to create a broadcast."""
target: str
message_text: str = Field(..., min_length=1, max_length=4000)
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
media: BroadcastMediaRequest | None = None
# ============ Response ============
class BroadcastResponse(BaseModel):
"""Broadcast response."""
id: int
target_type: str
message_text: str | None = None
has_media: bool
media_type: str | None = None
media_file_id: str | None = None
media_caption: str | None = None
total_count: int
sent_count: int
failed_count: int
status: str # queued|in_progress|completed|partial|failed|cancelled|cancelling
admin_id: int | None = None
admin_name: str | None = None
created_at: datetime
completed_at: datetime | None = None
progress_percent: float = 0.0
# Email/channel fields
channel: str = 'telegram' # telegram|email|both
email_subject: str | None = None
email_html_content: str | None = None
class Config:
from_attributes = True
class BroadcastListResponse(BaseModel):
"""Paginated list of broadcasts."""
items: list[BroadcastResponse]
total: int
limit: int
offset: int
# ============ Preview ============
class BroadcastPreviewRequest(BaseModel):
"""Request to preview broadcast recipients count."""
target: str
class BroadcastPreviewResponse(BaseModel):
"""Preview response with recipients count."""
target: str
count: int
# ============ Email Filters ============
class EmailFilterItem(BaseModel):
"""Single email filter with count."""
key: str
label: str
count: int
group: str | None = None
class EmailFiltersResponse(BaseModel):
"""Response with all email filters and their counts."""
filters: list[EmailFilterItem]
total_with_email: int
# ============ Combined Broadcast ============
class CombinedBroadcastCreateRequest(BaseModel):
"""Request to create a combined (telegram/email/both) broadcast."""
channel: BroadcastChannel
target: str
# Telegram-specific fields
message_text: str | None = Field(default=None, max_length=4000)
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
media: BroadcastMediaRequest | None = None
# Email-specific fields
email_subject: str | None = Field(default=None, max_length=255)
email_html_content: str | None = Field(default=None, max_length=100000)
# ============ Email Preview ============
class EmailPreviewRequest(BaseModel):
"""Request to preview email broadcast recipients."""
target: str
class EmailPreviewResponse(BaseModel):
"""Preview response for email broadcast."""
target: str
count: int
-203
View File
@@ -1,203 +0,0 @@
"""Schemas for advertising campaigns management in cabinet."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
CampaignBonusType = Literal['balance', 'subscription', 'none', 'tariff']
class TariffInfo(BaseModel):
"""Tariff info for campaign."""
id: int
name: str
class CampaignListItem(BaseModel):
"""Campaign item for list view."""
id: int
name: str
start_parameter: str
bonus_type: CampaignBonusType
is_active: bool
registrations_count: int
total_revenue_kopeks: int = 0
conversion_rate: float = 0.0
created_at: datetime
class Config:
from_attributes = True
class CampaignListResponse(BaseModel):
"""Response with list of campaigns."""
campaigns: list[CampaignListItem]
total: int
class CampaignDetailResponse(BaseModel):
"""Detailed campaign response."""
id: int
name: str
start_parameter: str
bonus_type: CampaignBonusType
is_active: bool
# Balance bonus
balance_bonus_kopeks: int = 0
balance_bonus_rubles: float = 0.0
# Subscription bonus
subscription_duration_days: int | None = None
subscription_traffic_gb: int | None = None
subscription_device_limit: int | None = None
subscription_squads: list[str] = Field(default_factory=list)
# Tariff bonus
tariff_id: int | None = None
tariff_duration_days: int | None = None
tariff: TariffInfo | None = None
# Meta
created_by: int | None = None
created_at: datetime
updated_at: datetime | None = None
# Deep link
deep_link: str | None = None
class Config:
from_attributes = True
class CampaignCreateRequest(BaseModel):
"""Request to create a campaign."""
name: str = Field(..., min_length=1, max_length=255)
start_parameter: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
bonus_type: CampaignBonusType
is_active: bool = True
# Balance bonus
balance_bonus_kopeks: int = Field(0, ge=0)
# Subscription bonus
subscription_duration_days: int | None = Field(None, ge=1)
subscription_traffic_gb: int | None = Field(None, ge=0)
subscription_device_limit: int | None = Field(None, ge=1)
subscription_squads: list[str] = Field(default_factory=list)
# Tariff bonus
tariff_id: int | None = None
tariff_duration_days: int | None = Field(None, ge=1)
class CampaignUpdateRequest(BaseModel):
"""Request to update a campaign."""
name: str | None = Field(None, min_length=1, max_length=255)
start_parameter: str | None = Field(None, min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
bonus_type: CampaignBonusType | None = None
is_active: bool | None = None
# Balance bonus
balance_bonus_kopeks: int | None = Field(None, ge=0)
# Subscription bonus
subscription_duration_days: int | None = Field(None, ge=1)
subscription_traffic_gb: int | None = Field(None, ge=0)
subscription_device_limit: int | None = Field(None, ge=1)
subscription_squads: list[str] | None = None
# Tariff bonus
tariff_id: int | None = None
tariff_duration_days: int | None = Field(None, ge=1)
class CampaignToggleResponse(BaseModel):
"""Response after toggling campaign."""
id: int
is_active: bool
message: str
class CampaignStatisticsResponse(BaseModel):
"""Detailed campaign statistics."""
id: int
name: str
start_parameter: str
bonus_type: CampaignBonusType
is_active: bool
# Registration stats
registrations: int = 0
balance_issued_kopeks: int = 0
balance_issued_rubles: float = 0.0
subscription_issued: int = 0
last_registration: datetime | None = None
# Revenue stats
total_revenue_kopeks: int = 0
total_revenue_rubles: float = 0.0
avg_revenue_per_user_kopeks: int = 0
avg_revenue_per_user_rubles: float = 0.0
avg_first_payment_kopeks: int = 0
avg_first_payment_rubles: float = 0.0
# Trial & Conversion stats
trial_users_count: int = 0
active_trials_count: int = 0
conversion_count: int = 0
paid_users_count: int = 0
conversion_rate: float = 0.0
trial_conversion_rate: float = 0.0
# Deep link
deep_link: str | None = None
class CampaignRegistrationItem(BaseModel):
"""Campaign registration item."""
id: int
user_id: int
telegram_id: int | None = None
username: str | None = None
first_name: str | None = None
bonus_type: str
balance_bonus_kopeks: int = 0
subscription_duration_days: int | None = None
tariff_id: int | None = None
tariff_duration_days: int | None = None
created_at: datetime
# User stats
user_balance_kopeks: int = 0
has_subscription: bool = False
has_paid: bool = False
class Config:
from_attributes = True
class CampaignRegistrationsResponse(BaseModel):
"""Response with campaign registrations."""
registrations: list[CampaignRegistrationItem]
total: int
page: int
per_page: int
class CampaignsOverviewResponse(BaseModel):
"""Overview of all campaigns."""
total: int
active: int
inactive: int
total_registrations: int
total_balance_issued_kopeks: int
total_balance_issued_rubles: float
total_subscription_issued: int
total_tariff_issued: int = 0
class ServerSquadInfo(BaseModel):
"""Server squad info for campaign selection."""
id: int
squad_uuid: str
display_name: str
country_code: str | None = None
-78
View File
@@ -1,78 +0,0 @@
"""Referral program schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel
class ReferralInfoResponse(BaseModel):
"""Referral program info for current user."""
referral_code: str
referral_link: str
total_referrals: int
active_referrals: int
total_earnings_kopeks: int
total_earnings_rubles: float
commission_percent: int
class ReferralItemResponse(BaseModel):
"""Single referral info."""
id: int
username: str | None = None
first_name: str | None = None
created_at: datetime
has_subscription: bool
has_paid: bool
class ReferralListResponse(BaseModel):
"""Paginated referral list."""
items: list[ReferralItemResponse]
total: int
page: int
per_page: int
pages: int
class ReferralEarningResponse(BaseModel):
"""Referral earning history item."""
id: int
amount_kopeks: int
amount_rubles: float
reason: str
referral_username: str | None = None
referral_first_name: str | None = None
created_at: datetime
class Config:
from_attributes = True
class ReferralEarningsListResponse(BaseModel):
"""Paginated referral earnings list."""
items: list[ReferralEarningResponse]
total: int
total_amount_kopeks: int
total_amount_rubles: float
page: int
per_page: int
pages: int
class ReferralTermsResponse(BaseModel):
"""Referral program terms."""
is_enabled: bool
commission_percent: int
minimum_topup_kopeks: int
minimum_topup_rubles: float
first_topup_bonus_kopeks: int
first_topup_bonus_rubles: float
inviter_bonus_kopeks: int
inviter_bonus_rubles: float
-396
View File
@@ -1,396 +0,0 @@
"""Schemas for RemnaWave management in cabinet admin panel."""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
# ============ Status & Connection ============
class ConnectionStatus(BaseModel):
"""RemnaWave API connection status."""
status: str
message: str
api_url: str | None = None
status_code: int | None = None
system_info: dict[str, Any] | None = None
class RemnaWaveStatusResponse(BaseModel):
"""RemnaWave configuration and connection status."""
is_configured: bool
configuration_error: str | None = None
connection: ConnectionStatus | None = None
# ============ System Statistics ============
class SystemSummary(BaseModel):
"""System summary statistics."""
users_online: int
total_users: int
active_connections: int
nodes_online: int
users_last_day: int
users_last_week: int
users_never_online: int
total_user_traffic: int
class ServerInfo(BaseModel):
"""Server hardware info."""
cpu_cores: int
cpu_physical_cores: int
memory_total: int
memory_used: int
memory_free: int
memory_available: int
uptime_seconds: int
class Bandwidth(BaseModel):
"""Realtime bandwidth statistics."""
realtime_download: int
realtime_upload: int
realtime_total: int
class TrafficPeriod(BaseModel):
"""Traffic statistics for a period."""
current: int
previous: int
difference: str | None = None
class TrafficPeriods(BaseModel):
"""Traffic statistics for multiple periods."""
last_2_days: TrafficPeriod
last_7_days: TrafficPeriod
last_30_days: TrafficPeriod
current_month: TrafficPeriod
current_year: TrafficPeriod
class SystemStatsResponse(BaseModel):
"""Full system statistics response."""
system: SystemSummary
users_by_status: dict[str, int]
server_info: ServerInfo
bandwidth: Bandwidth
traffic_periods: TrafficPeriods
nodes_realtime: list[dict[str, Any]] = Field(default_factory=list)
nodes_weekly: list[dict[str, Any]] = Field(default_factory=list)
last_updated: datetime | None = None
# ============ Nodes ============
class NodeInfo(BaseModel):
"""Node information."""
uuid: str
name: str
address: str
country_code: str | None = None
is_connected: bool
is_disabled: bool
is_node_online: bool
is_xray_running: bool
users_online: int | None = None
traffic_used_bytes: int | None = None
traffic_limit_bytes: int | None = None
last_status_change: datetime | None = None
last_status_message: str | None = None
xray_uptime: str | None = None
is_traffic_tracking_active: bool = False
traffic_reset_day: int | None = None
notify_percent: int | None = None
consumption_multiplier: float = 1.0
cpu_count: int | None = None
cpu_model: str | None = None
total_ram: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
provider_uuid: str | None = None
class NodesListResponse(BaseModel):
"""List of nodes response."""
items: list[NodeInfo]
total: int
class NodesOverview(BaseModel):
"""Nodes overview statistics."""
total: int
online: int
offline: int
disabled: int
total_users_online: int
nodes: list[NodeInfo]
class NodeStatisticsResponse(BaseModel):
"""Node statistics with usage history."""
node: NodeInfo
realtime: dict[str, Any] | None = None
usage_history: list[dict[str, Any]] = Field(default_factory=list)
last_updated: datetime | None = None
class NodeUsageResponse(BaseModel):
"""Node usage history response."""
items: list[dict[str, Any]] = Field(default_factory=list)
class NodeActionRequest(BaseModel):
"""Request to perform node action."""
action: Literal['enable', 'disable', 'restart']
class NodeActionResponse(BaseModel):
"""Response after node action."""
success: bool
message: str | None = None
is_disabled: bool | None = None
# ============ Squads (Internal Squads) ============
class SquadInfo(BaseModel):
"""Internal Squad information from RemnaWave."""
uuid: str
name: str
members_count: int
inbounds_count: int
inbounds: list[dict[str, Any]] = Field(default_factory=list)
class SquadWithLocalInfo(BaseModel):
"""Squad with local database info."""
uuid: str
name: str
members_count: int
inbounds_count: int
inbounds: list[dict[str, Any]] = Field(default_factory=list)
# Local DB info
local_id: int | None = None
display_name: str | None = None
country_code: str | None = None
is_available: bool | None = None
is_trial_eligible: bool | None = None
price_kopeks: int | None = None
max_users: int | None = None
current_users: int | None = None
is_synced: bool = False
class SquadsListResponse(BaseModel):
"""List of squads response."""
items: list[SquadWithLocalInfo]
total: int
class SquadDetailResponse(BaseModel):
"""Detailed squad response."""
uuid: str
name: str
members_count: int
inbounds_count: int
inbounds: list[dict[str, Any]] = Field(default_factory=list)
# Local DB info if synced
local_id: int | None = None
display_name: str | None = None
country_code: str | None = None
description: str | None = None
is_available: bool | None = None
is_trial_eligible: bool | None = None
price_kopeks: int | None = None
max_users: int | None = None
current_users: int | None = None
sort_order: int | None = None
is_synced: bool = False
active_subscriptions: int = 0
class SquadCreateRequest(BaseModel):
"""Request to create a new squad."""
name: str = Field(..., min_length=1, max_length=255)
inbound_uuids: list[str] = Field(default_factory=list)
class SquadUpdateRequest(BaseModel):
"""Request to update a squad."""
name: str | None = Field(None, min_length=1, max_length=255)
inbound_uuids: list[str] | None = None
class SquadActionRequest(BaseModel):
"""Request to perform squad action."""
action: Literal['add_all_users', 'remove_all_users', 'delete', 'rename', 'update_inbounds']
name: str | None = None
inbound_uuids: list[str] | None = None
class SquadOperationResponse(BaseModel):
"""Response after squad operation."""
success: bool
message: str | None = None
data: dict[str, Any] | None = None
# ============ Migration ============
class MigrationPreviewResponse(BaseModel):
"""Preview of squad migration."""
squad_uuid: str
squad_name: str
current_users: int
max_users: int | None = None
users_to_migrate: int
class MigrationRequest(BaseModel):
"""Request to migrate users between squads."""
source_uuid: str
target_uuid: str
class MigrationStats(BaseModel):
"""Migration statistics."""
source_uuid: str
target_uuid: str
total: int = 0
updated: int = 0
panel_updated: int = 0
panel_failed: int = 0
source_removed: int = 0
target_added: int = 0
class MigrationResponse(BaseModel):
"""Response after migration."""
success: bool
message: str | None = None
error: str | None = None
data: MigrationStats | None = None
# ============ Inbounds ============
class InboundInfo(BaseModel):
"""Inbound information."""
uuid: str
tag: str
type: str | None = None
network: str | None = None
security: str | None = None
class InboundsListResponse(BaseModel):
"""List of inbounds response."""
items: list[dict[str, Any]] = Field(default_factory=list)
total: int = 0
# ============ Auto Sync ============
class AutoSyncTime(BaseModel):
"""Scheduled sync time."""
hour: int
minute: int
class AutoSyncStatus(BaseModel):
"""Auto sync status."""
enabled: bool
times: list[str] = Field(default_factory=list) # HH:MM format
next_run: datetime | None = None
is_running: bool = False
last_run_started_at: datetime | None = None
last_run_finished_at: datetime | None = None
last_run_success: bool | None = None
last_run_reason: str | None = None
last_run_error: str | None = None
last_user_stats: dict[str, Any] | None = None
last_server_stats: dict[str, Any] | None = None
class AutoSyncToggleRequest(BaseModel):
"""Request to toggle auto sync."""
enabled: bool
class AutoSyncRunResponse(BaseModel):
"""Response after running sync."""
started: bool
success: bool | None = None
error: str | None = None
user_stats: dict[str, Any] | None = None
server_stats: dict[str, Any] | None = None
reason: str | None = None
# ============ Manual Sync ============
class SyncMode(BaseModel):
"""Sync mode options."""
mode: Literal['all', 'new_only', 'update_only'] = 'all'
class SyncResponse(BaseModel):
"""Response after sync operation."""
success: bool
message: str | None = None
data: dict[str, Any] | None = None
class SyncRecommendations(BaseModel):
"""Sync recommendations."""
success: bool
message: str | None = None
data: dict[str, Any] | None = None
-129
View File
@@ -1,129 +0,0 @@
"""Schemas for server management in cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
class PromoGroupInfo(BaseModel):
"""Promo group info for server."""
id: int
name: str
is_selected: bool = False
class ServerListItem(BaseModel):
"""Server item for list view."""
id: int
squad_uuid: str
display_name: str
original_name: str | None = None
country_code: str | None = None
is_available: bool
is_trial_eligible: bool
price_kopeks: int
price_rubles: float
max_users: int | None = None
current_users: int
sort_order: int
is_full: bool
availability_status: str
created_at: datetime
class Config:
from_attributes = True
class ServerListResponse(BaseModel):
"""Response with list of servers."""
servers: list[ServerListItem]
total: int
class ServerDetailResponse(BaseModel):
"""Detailed server response."""
id: int
squad_uuid: str
display_name: str
original_name: str | None = None
country_code: str | None = None
description: str | None = None
is_available: bool
is_trial_eligible: bool
price_kopeks: int
price_rubles: float
max_users: int | None = None
current_users: int
sort_order: int
is_full: bool
availability_status: str
promo_groups: list[PromoGroupInfo]
active_subscriptions: int
tariffs_using: list[str] # Names of tariffs using this server
created_at: datetime
updated_at: datetime | None = None
class Config:
from_attributes = True
class ServerUpdateRequest(BaseModel):
"""Request to update a server."""
display_name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
country_code: str | None = Field(None, max_length=5)
is_available: bool | None = None
is_trial_eligible: bool | None = None
price_kopeks: int | None = Field(None, ge=0)
max_users: int | None = Field(None, ge=0)
sort_order: int | None = Field(None, ge=0)
promo_group_ids: list[int] | None = None
class ServerToggleResponse(BaseModel):
"""Response after toggling server."""
id: int
is_available: bool
message: str
class ServerTrialToggleResponse(BaseModel):
"""Response after toggling trial eligibility."""
id: int
is_trial_eligible: bool
message: str
class ServerStatsResponse(BaseModel):
"""Server statistics."""
id: int
display_name: str
squad_uuid: str
current_users: int
max_users: int | None
active_subscriptions: int
trial_subscriptions: int
usage_percent: float | None = None
class ServerSyncResponse(BaseModel):
"""Response after syncing with RemnaWave."""
created: int
updated: int
removed: int
message: str
class ServerSyncRequest(BaseModel):
"""Request to sync servers."""
force: bool = False # Force sync even if recently synced
-159
View File
@@ -1,159 +0,0 @@
"""Subscription schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
class ServerInfo(BaseModel):
"""Server info for display."""
uuid: str
name: str
country_code: str | None = None
class TrafficPurchaseInfo(BaseModel):
"""Purchased traffic package info."""
id: int
traffic_gb: int
expires_at: datetime
created_at: datetime
days_remaining: int
progress_percent: float
class SubscriptionData(BaseModel):
"""User subscription data."""
id: int
status: str
is_trial: bool
start_date: datetime
end_date: datetime
days_left: int
hours_left: int = 0
minutes_left: int = 0
time_left_display: str = '' # Human readable format like "2д 5ч" or "5ч 30м"
traffic_limit_gb: int
traffic_used_gb: float
traffic_used_percent: float
device_limit: int
connected_squads: list[str] = []
servers: list[ServerInfo] = [] # Server display info
autopay_enabled: bool
autopay_days_before: int
subscription_url: str | None = None
hide_subscription_link: bool = False # Скрывать ли отображение ссылки (но кнопки работают)
is_active: bool
is_expired: bool
traffic_purchases: list[TrafficPurchaseInfo] = []
# Daily tariff fields
is_daily: bool = False
is_daily_paused: bool = False
daily_price_kopeks: int | None = None
next_daily_charge_at: datetime | None = None # When next daily charge will happen
tariff_id: int | None = None
tariff_name: str | None = None
class Config:
from_attributes = True
# Backward compatibility alias
SubscriptionResponse = SubscriptionData
class SubscriptionStatusResponse(BaseModel):
"""Response for subscription status endpoint - handles users with and without subscription."""
has_subscription: bool
subscription: SubscriptionData | None = None
class RenewalOptionResponse(BaseModel):
"""Available subscription renewal option."""
period_days: int
price_kopeks: int
price_rubles: float
discount_percent: int = 0
original_price_kopeks: int | None = None
class RenewalRequest(BaseModel):
"""Request to renew subscription."""
period_days: int = Field(..., description='Renewal period in days')
class TrafficPackageResponse(BaseModel):
"""Available traffic package."""
gb: int
price_kopeks: int
price_rubles: float
is_unlimited: bool = False
class TrafficPurchaseRequest(BaseModel):
"""Request to purchase additional traffic."""
gb: int = Field(..., ge=0, description='GB to purchase (0 = unlimited)')
class DevicePurchaseRequest(BaseModel):
"""Request to purchase additional device slots."""
devices: int = Field(..., ge=1, description='Number of additional devices')
class AutopayUpdateRequest(BaseModel):
"""Request to update autopay settings."""
enabled: bool
days_before: int | None = Field(None, ge=1, le=30, description='Days before expiration to charge')
class TrialInfoResponse(BaseModel):
"""Trial subscription info."""
is_available: bool
duration_days: int
traffic_limit_gb: int
device_limit: int
requires_payment: bool = False
price_kopeks: int = 0
price_rubles: float = 0.0
reason_unavailable: str | None = None
# ============ Purchase Options Schemas ============
class PurchaseSelectionRequest(BaseModel):
"""User's selection for subscription purchase."""
period_id: str | None = Field(None, description="Period ID like 'days:30'")
period_days: int | None = Field(None, description='Period in days')
traffic_value: int | None = Field(None, description='Traffic in GB (0 = unlimited)')
servers: list[str] | None = Field(default_factory=list, description='Server UUIDs')
devices: int | None = Field(None, description='Device limit')
class PurchasePreviewRequest(BaseModel):
"""Request to preview purchase pricing."""
selection: PurchaseSelectionRequest
# ============ Tariff Purchase Schemas ============
class TariffPurchaseRequest(BaseModel):
"""Request to purchase a tariff."""
tariff_id: int = Field(..., description='Tariff ID to purchase')
period_days: int = Field(..., description='Period in days')
traffic_gb: int | None = Field(None, ge=0, description='Custom traffic in GB (for custom_traffic_enabled tariffs)')
-228
View File
@@ -1,228 +0,0 @@
"""Schemas for tariff management in cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
class PeriodPrice(BaseModel):
"""Price for a specific period."""
days: int = Field(..., ge=1, description='Period in days')
price_kopeks: int = Field(..., ge=0, description='Price in kopeks')
price_rubles: float | None = None
def __init__(self, **data):
super().__init__(**data)
if self.price_rubles is None:
self.price_rubles = self.price_kopeks / 100
class ServerTrafficLimit(BaseModel):
"""Traffic limit for a specific server."""
traffic_limit_gb: int = Field(0, ge=0, description='0 = use default tariff limit')
class ServerInfo(BaseModel):
"""Server info for tariff."""
id: int
squad_uuid: str
display_name: str
country_code: str | None = None
is_selected: bool = False
traffic_limit_gb: int | None = None # Индивидуальный лимит для сервера
class PromoGroupInfo(BaseModel):
"""Promo group info for tariff."""
id: int
name: str
is_selected: bool = False
class TariffListItem(BaseModel):
"""Tariff item for list view."""
id: int
name: str
description: str | None = None
is_active: bool
is_trial_available: bool
is_daily: bool = False
daily_price_kopeks: int = 0
allow_traffic_topup: bool = True
traffic_limit_gb: int
device_limit: int
tier_level: int
display_order: int
servers_count: int
subscriptions_count: int
created_at: datetime
class Config:
from_attributes = True
class TariffListResponse(BaseModel):
"""Response with list of tariffs."""
tariffs: list[TariffListItem]
total: int
class TariffDetailResponse(BaseModel):
"""Detailed tariff response."""
id: int
name: str
description: str | None = None
is_active: bool
is_trial_available: bool
allow_traffic_topup: bool = True
traffic_topup_enabled: bool = False
traffic_topup_packages: dict[str, int] = Field(default_factory=dict)
max_topup_traffic_gb: int = 0
traffic_limit_gb: int
device_limit: int
device_price_kopeks: int | None = None
max_device_limit: int | None = None
tier_level: int
display_order: int
period_prices: list[PeriodPrice]
allowed_squads: list[str] # UUIDs
server_traffic_limits: dict[str, ServerTrafficLimit] = Field(default_factory=dict) # {uuid: {traffic_limit_gb}}
servers: list[ServerInfo]
promo_groups: list[PromoGroupInfo]
subscriptions_count: int
# Произвольное количество дней
custom_days_enabled: bool = False
price_per_day_kopeks: int = 0
min_days: int = 1
max_days: int = 365
# Произвольный трафик при покупке
custom_traffic_enabled: bool = False
traffic_price_per_gb_kopeks: int = 0
min_traffic_gb: int = 1
max_traffic_gb: int = 1000
# Дневной тариф
is_daily: bool = False
daily_price_kopeks: int = 0
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
created_at: datetime
updated_at: datetime | None = None
class Config:
from_attributes = True
class TariffCreateRequest(BaseModel):
"""Request to create a tariff."""
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
is_active: bool = True
allow_traffic_topup: bool = True
traffic_topup_enabled: bool = False
traffic_topup_packages: dict[str, int] = Field(default_factory=dict)
max_topup_traffic_gb: int = Field(0, ge=0)
traffic_limit_gb: int = Field(0, ge=0, description='0 = unlimited')
device_limit: int = Field(1, ge=1)
device_price_kopeks: int | None = Field(None, ge=0)
max_device_limit: int | None = Field(None, ge=1)
tier_level: int = Field(1, ge=1, le=10)
period_prices: list[PeriodPrice] = Field(default_factory=list)
allowed_squads: list[str] = Field(default_factory=list, description='Server UUIDs')
server_traffic_limits: dict[str, ServerTrafficLimit] = Field(
default_factory=dict, description='Per-server traffic limits'
)
promo_group_ids: list[int] = Field(default_factory=list)
# Произвольное количество дней
custom_days_enabled: bool = False
price_per_day_kopeks: int = Field(0, ge=0)
min_days: int = Field(1, ge=1)
max_days: int = Field(365, ge=1)
# Произвольный трафик при покупке
custom_traffic_enabled: bool = False
traffic_price_per_gb_kopeks: int = Field(0, ge=0)
min_traffic_gb: int = Field(1, ge=1)
max_traffic_gb: int = Field(1000, ge=1)
# Дневной тариф
is_daily: bool = False
daily_price_kopeks: int = Field(0, ge=0)
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
class TariffUpdateRequest(BaseModel):
"""Request to update a tariff."""
name: str | None = Field(None, min_length=1, max_length=255)
description: str | None = None
is_active: bool | None = None
allow_traffic_topup: bool | None = None
traffic_topup_enabled: bool | None = None
traffic_topup_packages: dict[str, int] | None = None
max_topup_traffic_gb: int | None = Field(None, ge=0)
traffic_limit_gb: int | None = Field(None, ge=0)
device_limit: int | None = Field(None, ge=1)
device_price_kopeks: int | None = Field(None, ge=0)
max_device_limit: int | None = Field(None, ge=1)
tier_level: int | None = Field(None, ge=1, le=10)
display_order: int | None = Field(None, ge=0)
period_prices: list[PeriodPrice] | None = None
allowed_squads: list[str] | None = None
server_traffic_limits: dict[str, ServerTrafficLimit] | None = None
promo_group_ids: list[int] | None = None
# Произвольное количество дней
custom_days_enabled: bool | None = None
price_per_day_kopeks: int | None = Field(None, ge=0)
min_days: int | None = Field(None, ge=1)
max_days: int | None = Field(None, ge=1)
# Произвольный трафик при покупке
custom_traffic_enabled: bool | None = None
traffic_price_per_gb_kopeks: int | None = Field(None, ge=0)
min_traffic_gb: int | None = Field(None, ge=1)
max_traffic_gb: int | None = Field(None, ge=1)
# Дневной тариф
is_daily: bool | None = None
daily_price_kopeks: int | None = Field(None, ge=0)
# Режим сброса трафика
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
class TariffSortOrderRequest(BaseModel):
"""Request to reorder tariffs."""
tariff_ids: list[int] = Field(..., min_length=1, description='Ordered list of tariff IDs')
class TariffToggleResponse(BaseModel):
"""Response after toggling tariff."""
id: int
is_active: bool
message: str
class TariffTrialResponse(BaseModel):
"""Response after setting trial tariff."""
id: int
is_trial_available: bool
message: str
class TariffStatsResponse(BaseModel):
"""Tariff statistics."""
id: int
name: str
subscriptions_count: int
active_subscriptions: int
trial_subscriptions: int
revenue_kopeks: int
revenue_rubles: float
-84
View File
@@ -1,84 +0,0 @@
"""Support tickets schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
class TicketMessageResponse(BaseModel):
"""Ticket message data."""
id: int
message_text: str
is_from_admin: bool
has_media: bool = False
media_type: str | None = None
media_file_id: str | None = None
media_caption: str | None = None
created_at: datetime
class Config:
from_attributes = True
class TicketResponse(BaseModel):
"""Ticket data."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: datetime | None = None
messages_count: int = 0
last_message: TicketMessageResponse | None = None
class Config:
from_attributes = True
class TicketDetailResponse(BaseModel):
"""Ticket with all messages."""
id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: datetime | None = None
is_reply_blocked: bool = False
messages: list[TicketMessageResponse] = []
class Config:
from_attributes = True
class TicketListResponse(BaseModel):
"""Paginated ticket list."""
items: list[TicketResponse]
total: int
page: int
per_page: int
pages: int
class TicketCreateRequest(BaseModel):
"""Request to create a new ticket."""
title: str = Field(..., min_length=3, max_length=255, description='Ticket title')
message: str = Field(..., min_length=10, max_length=4000, description='Initial message')
media_type: str | None = Field(None, description='Media type: photo, video, document')
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
class TicketMessageCreateRequest(BaseModel):
"""Request to add message to ticket."""
message: str = Field(..., min_length=1, max_length=4000, description='Message text')
media_type: str | None = Field(None, description='Media type: photo, video, document')
media_file_id: str | None = Field(None, description='Telegram file_id of uploaded media')
media_caption: str | None = Field(None, max_length=1000, description='Media caption')
-62
View File
@@ -1,62 +0,0 @@
"""Schemas for admin traffic usage."""
from pydantic import BaseModel, Field
class TrafficNodeInfo(BaseModel):
node_uuid: str
node_name: str
country_code: str
class UserTrafficItem(BaseModel):
user_id: int
telegram_id: int | None
username: str | None
email: str | None
full_name: str
tariff_name: str | None
subscription_status: str | None
traffic_limit_gb: float
device_limit: int
node_traffic: dict[str, int] # {node_uuid: total_bytes}
total_bytes: int
class TrafficUsageResponse(BaseModel):
items: list[UserTrafficItem]
nodes: list[TrafficNodeInfo]
total: int
offset: int
limit: int
period_days: int
available_tariffs: list[str]
available_statuses: list[str]
class UserTrafficEnrichment(BaseModel):
devices_connected: int = 0
total_spent_kopeks: int = 0
subscription_start_date: str | None = None
subscription_end_date: str | None = None
last_node_name: str | None = None
class TrafficEnrichmentResponse(BaseModel):
data: dict[int, UserTrafficEnrichment]
class ExportCsvRequest(BaseModel):
period: int = Field(30, ge=1, le=30)
start_date: str | None = None
end_date: str | None = None
tariffs: str | None = None
statuses: str | None = None
nodes: str | None = None
total_threshold_gb: float | None = Field(None, ge=0, description='Total GB/day threshold for risk column')
node_threshold_gb: float | None = Field(None, ge=0, description='Per-node GB/day threshold for risk column')
class ExportCsvResponse(BaseModel):
success: bool
message: str
-696
View File
@@ -1,696 +0,0 @@
"""Schemas for Admin Users management in cabinet."""
from datetime import datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field
class UserStatusEnum(str, Enum):
"""User status enum."""
ACTIVE = 'active'
BLOCKED = 'blocked'
DELETED = 'deleted'
class SubscriptionStatusEnum(str, Enum):
"""Subscription status enum."""
TRIAL = 'trial'
ACTIVE = 'active'
EXPIRED = 'expired'
DISABLED = 'disabled'
PENDING = 'pending'
class SortByEnum(str, Enum):
"""Sort options for users list."""
CREATED_AT = 'created_at'
BALANCE = 'balance'
TRAFFIC = 'traffic'
LAST_ACTIVITY = 'last_activity'
TOTAL_SPENT = 'total_spent'
PURCHASE_COUNT = 'purchase_count'
# === User Subscription Info ===
class TrafficPurchaseItem(BaseModel):
"""Individual traffic purchase record."""
id: int
traffic_gb: int
expires_at: datetime
created_at: datetime
days_remaining: int
is_expired: bool
class UserSubscriptionInfo(BaseModel):
"""User subscription information."""
id: int
status: str
is_trial: bool
start_date: datetime | None = None
end_date: datetime | None = None
traffic_limit_gb: int = 0
traffic_used_gb: float = 0.0
device_limit: int = 1
tariff_id: int | None = None
tariff_name: str | None = None
autopay_enabled: bool = False
is_active: bool = False
days_remaining: int = 0
purchased_traffic_gb: int = 0
traffic_purchases: list[TrafficPurchaseItem] = []
class UserPromoGroupInfo(BaseModel):
"""User promo group info."""
id: int
name: str
is_default: bool = False
# === User List ===
class UserListItem(BaseModel):
"""User item in list."""
id: int
telegram_id: int | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
full_name: str
status: str
balance_kopeks: int
balance_rubles: float
created_at: datetime
last_activity: datetime | None = None
# Subscription summary
has_subscription: bool = False
subscription_status: str | None = None
subscription_is_trial: bool = False
subscription_end_date: datetime | None = None
# Promo group
promo_group_id: int | None = None
promo_group_name: str | None = None
# Stats
total_spent_kopeks: int = 0
purchase_count: int = 0
# Restrictions
has_restrictions: bool = False
restriction_topup: bool = False
restriction_subscription: bool = False
class UsersListResponse(BaseModel):
"""Paginated list of users."""
users: list[UserListItem]
total: int
offset: int = 0
limit: int = 50
# === User Detail ===
class UserTransactionItem(BaseModel):
"""User transaction."""
id: int
type: str
amount_kopeks: int
amount_rubles: float
description: str | None = None
payment_method: str | None = None
is_completed: bool = True
created_at: datetime
class UserReferralInfo(BaseModel):
"""User referral info."""
referral_code: str
referrals_count: int = 0
total_earnings_kopeks: int = 0
commission_percent: int | None = None
referred_by_id: int | None = None
referred_by_username: str | None = None
class UserDetailResponse(BaseModel):
"""Detailed user information."""
id: int
telegram_id: int | None = None
username: str | None = None
first_name: str | None = None
last_name: str | None = None
full_name: str
status: str
language: str
balance_kopeks: int
balance_rubles: float
# Email (cabinet)
email: str | None = None
email_verified: bool = False
# Dates
created_at: datetime
updated_at: datetime | None = None
last_activity: datetime | None = None
cabinet_last_login: datetime | None = None
# Subscription
subscription: UserSubscriptionInfo | None = None
# Promo group
promo_group: UserPromoGroupInfo | None = None
# Referral
referral: UserReferralInfo
# Stats
total_spent_kopeks: int = 0
purchase_count: int = 0
used_promocodes: int = 0
has_had_paid_subscription: bool = False
lifetime_used_traffic_bytes: int = 0
# Restrictions
restriction_topup: bool = False
restriction_subscription: bool = False
restriction_reason: str | None = None
# Promo offer
promo_offer_discount_percent: int = 0
promo_offer_discount_source: str | None = None
promo_offer_discount_expires_at: datetime | None = None
# Campaign
campaign_name: str | None = None
campaign_id: int | None = None
# Recent transactions
recent_transactions: list[UserTransactionItem] = []
# Remnawave UUID
remnawave_uuid: str | None = None
# === Panel Info ===
class UserPanelInfoResponse(BaseModel):
"""Panel info for user from Remnawave."""
found: bool = False
trojan_password: str | None = None
vless_uuid: str | None = None
ss_password: str | None = None
subscription_url: str | None = None
happ_link: str | None = None
used_traffic_bytes: int = 0
lifetime_used_traffic_bytes: int = 0
traffic_limit_bytes: int = 0
first_connected_at: datetime | None = None
online_at: datetime | None = None
last_connected_node_uuid: str | None = None
last_connected_node_name: str | None = None
# === Node Usage ===
class UserNodeUsageItem(BaseModel):
"""Per-node traffic usage item."""
node_uuid: str
node_name: str
country_code: str = ''
total_bytes: int
daily_bytes: list[int] = []
class UserNodeUsageResponse(BaseModel):
"""Node usage response with 30-day daily breakdown."""
items: list[UserNodeUsageItem]
categories: list[str] = []
period_days: int = 30
# === User Actions ===
class UpdateBalanceRequest(BaseModel):
"""Request to update user balance."""
amount_kopeks: int = Field(..., description='Amount in kopeks (positive to add, negative to subtract)')
description: str = Field(default='Admin balance adjustment', max_length=500)
create_transaction: bool = Field(default=True, description='Create transaction record')
class UpdateBalanceResponse(BaseModel):
"""Response after balance update."""
success: bool
old_balance_kopeks: int
new_balance_kopeks: int
message: str
class UpdateSubscriptionRequest(BaseModel):
"""Request to update user subscription."""
action: str = Field(
..., description='Action: extend, set_end_date, change_tariff, set_traffic, toggle_autopay, cancel'
)
# For extend action
days: int | None = Field(None, ge=1, le=3650, description='Days to extend')
# For set_end_date action
end_date: datetime | None = Field(None, description='New end date')
# For change_tariff action
tariff_id: int | None = Field(None, description='New tariff ID')
# For set_traffic action
traffic_limit_gb: int | None = Field(None, ge=0, description='New traffic limit in GB')
traffic_used_gb: float | None = Field(None, ge=0, description='Set traffic used in GB')
# For toggle_autopay
autopay_enabled: bool | None = Field(None, description='Enable/disable autopay')
# For add_traffic action
traffic_gb: int | None = Field(None, ge=1, description='Traffic GB to add')
# For remove_traffic action
traffic_purchase_id: int | None = Field(None, description='Traffic purchase ID to remove')
# For create new subscription
is_trial: bool | None = Field(None, description='Is trial subscription')
device_limit: int | None = Field(None, ge=1, description='Device limit')
class UpdateSubscriptionResponse(BaseModel):
"""Response after subscription update."""
success: bool
message: str
subscription: UserSubscriptionInfo | None = None
class UpdateUserStatusRequest(BaseModel):
"""Request to update user status."""
status: UserStatusEnum
reason: str | None = Field(None, max_length=500, description='Reason for status change')
class UpdateUserStatusResponse(BaseModel):
"""Response after status update."""
success: bool
old_status: str
new_status: str
message: str
class UpdateRestrictionsRequest(BaseModel):
"""Request to update user restrictions."""
restriction_topup: bool | None = Field(None, description='Block balance top-up')
restriction_subscription: bool | None = Field(None, description='Block subscription purchase/renewal')
restriction_reason: str | None = Field(None, max_length=500, description='Reason for restrictions')
class UpdateRestrictionsResponse(BaseModel):
"""Response after restrictions update."""
success: bool
restriction_topup: bool
restriction_subscription: bool
restriction_reason: str | None = None
message: str
class UpdatePromoGroupRequest(BaseModel):
"""Request to update user promo group."""
promo_group_id: int | None = Field(None, description='New promo group ID (null to remove)')
class UpdatePromoGroupResponse(BaseModel):
"""Response after promo group update."""
success: bool
old_promo_group_id: int | None = None
new_promo_group_id: int | None = None
promo_group_name: str | None = None
message: str
class UpdateReferralCommissionRequest(BaseModel):
"""Request to update user referral commission percent."""
commission_percent: int | None = Field(
None, ge=0, le=100, description='Referral commission percent (null for default)'
)
class UpdateReferralCommissionResponse(BaseModel):
"""Response after referral commission update."""
success: bool
old_commission_percent: int | None = None
new_commission_percent: int | None = None
message: str
class DeviceInfo(BaseModel):
"""Individual device info."""
hwid: str
platform: str = ''
device_model: str = ''
created_at: str | None = None
class UserDevicesResponse(BaseModel):
"""User devices from panel."""
devices: list[DeviceInfo] = []
total: int = 0
device_limit: int = 0
class DeleteDeviceResponse(BaseModel):
"""Response after device deletion."""
success: bool
message: str
deleted_hwid: str | None = None
class ResetDevicesResponse(BaseModel):
"""Response after resetting all devices."""
success: bool
message: str
deleted_count: int = 0
class DeleteUserRequest(BaseModel):
"""Request to delete user."""
soft_delete: bool = Field(default=True, description='Soft delete (mark as deleted) or hard delete')
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
class DeleteUserResponse(BaseModel):
"""Response after user deletion."""
success: bool
message: str
# === Statistics ===
class UsersStatsResponse(BaseModel):
"""Users statistics."""
total_users: int = 0
active_users: int = 0
blocked_users: int = 0
deleted_users: int = 0
new_today: int = 0
new_week: int = 0
new_month: int = 0
# Subscription stats
users_with_subscription: int = 0
users_with_active_subscription: int = 0
users_with_trial: int = 0
users_with_expired_subscription: int = 0
# Financial stats
total_balance_kopeks: int = 0
total_balance_rubles: float = 0.0
avg_balance_kopeks: int = 0
# Activity stats
active_today: int = 0
active_week: int = 0
active_month: int = 0
# === Search ===
class UserSearchRequest(BaseModel):
"""Request for user search."""
query: str = Field(..., min_length=1, max_length=255)
search_by: list[str] = Field(
default=['telegram_id', 'username', 'first_name', 'last_name', 'email'], description='Fields to search in'
)
limit: int = Field(default=20, ge=1, le=100)
# === Tariffs for User ===
class PeriodPriceInfo(BaseModel):
"""Period price info."""
days: int
price_kopeks: int
price_rubles: float
class UserAvailableTariffItem(BaseModel):
"""Tariff available for user."""
id: int
name: str
description: str | None = None
is_active: bool = True
is_trial_available: bool = False
traffic_limit_gb: int = 0
device_limit: int = 1
tier_level: int = 1
display_order: int = 0
# Pricing
period_prices: list[PeriodPriceInfo] = []
is_daily: bool = False
daily_price_kopeks: int = 0
# Custom options
custom_days_enabled: bool = False
price_per_day_kopeks: int = 0
min_days: int = 1
max_days: int = 365
# Device limits
device_price_kopeks: int | None = None
max_device_limit: int | None = None
# Traffic topup
traffic_topup_enabled: bool = False
traffic_topup_packages: dict[str, int] = {}
max_topup_traffic_gb: int = 0
# Access info
is_available: bool = True # Available for this user's promo group
requires_promo_group: bool = False # Requires specific promo group
class UserAvailableTariffsResponse(BaseModel):
"""List of tariffs available for user."""
user_id: int
promo_group_id: int | None = None
promo_group_name: str | None = None
tariffs: list[UserAvailableTariffItem] = []
total: int = 0
# Current subscription tariff
current_tariff_id: int | None = None
current_tariff_name: str | None = None
# === Panel Sync ===
class PanelUserInfo(BaseModel):
"""User info from panel."""
uuid: str | None = None
short_uuid: str | None = None
username: str | None = None
status: str | None = None
expire_at: datetime | None = None
traffic_limit_gb: float = 0
traffic_used_gb: float = 0
device_limit: int = 1
subscription_url: str | None = None
active_squads: list[str] = []
class SyncFromPanelRequest(BaseModel):
"""Request to sync user from panel."""
update_subscription: bool = Field(default=True, description='Update subscription data')
update_traffic: bool = Field(default=True, description='Update traffic usage')
create_if_missing: bool = Field(
default=False, description='Create subscription if user exists in panel but not in bot'
)
class SyncFromPanelResponse(BaseModel):
"""Response after syncing from panel."""
success: bool
message: str
panel_user: PanelUserInfo | None = None
changes: dict[str, Any] = {}
errors: list[str] = []
class SyncToPanelRequest(BaseModel):
"""Request to sync user to panel."""
create_if_missing: bool = Field(default=True, description='Create user in panel if not exists')
update_status: bool = Field(default=True, description='Update user status in panel')
update_traffic_limit: bool = Field(default=True, description='Update traffic limit in panel')
update_expire_date: bool = Field(default=True, description='Update expire date in panel')
update_squads: bool = Field(default=True, description='Update connected squads in panel')
class SyncToPanelResponse(BaseModel):
"""Response after syncing to panel."""
success: bool
message: str
action: str = '' # created, updated, no_changes
panel_uuid: str | None = None
changes: dict[str, Any] = {}
errors: list[str] = []
class PanelSyncStatusResponse(BaseModel):
"""Panel sync status for user."""
user_id: int
telegram_id: int | None = None
remnawave_uuid: str | None = None
last_sync: datetime | None = None
# Bot data
bot_subscription_status: str | None = None
bot_subscription_end_date: datetime | None = None
bot_traffic_limit_gb: int = 0
bot_traffic_used_gb: float = 0
bot_device_limit: int = 0
bot_squads: list[str] = []
# Panel data (if available)
panel_found: bool = False
panel_status: str | None = None
panel_expire_at: datetime | None = None
panel_traffic_limit_gb: float = 0
panel_traffic_used_gb: float = 0
panel_device_limit: int = 0
panel_squads: list[str] = []
# Differences
has_differences: bool = False
differences: list[str] = []
# === Admin User Management Actions ===
class FullDeleteUserRequest(BaseModel):
"""Request for full user deletion (bot + panel)."""
delete_from_panel: bool = Field(default=True, description='Also delete user from Remnawave panel')
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
class FullDeleteUserResponse(BaseModel):
"""Response after full user deletion."""
success: bool
message: str
deleted_from_bot: bool = False
deleted_from_panel: bool = False
panel_error: str | None = None
class ResetTrialRequest(BaseModel):
"""Request to reset user trial."""
reason: str | None = Field(None, max_length=500, description='Reason for trial reset')
class ResetTrialResponse(BaseModel):
"""Response after trial reset."""
success: bool
message: str
subscription_deleted: bool = False
has_used_trial_reset: bool = False
class ResetSubscriptionRequest(BaseModel):
"""Request to reset user subscription."""
deactivate_in_panel: bool = Field(default=True, description='Also deactivate in Remnawave panel')
reason: str | None = Field(None, max_length=500, description='Reason for subscription reset')
class ResetSubscriptionResponse(BaseModel):
"""Response after subscription reset."""
success: bool
message: str
subscription_deleted: bool = False
panel_deactivated: bool = False
panel_error: str | None = None
class DisableUserRequest(BaseModel):
"""Request to disable user."""
reason: str | None = Field(None, max_length=500, description='Reason for disabling')
class DisableUserResponse(BaseModel):
"""Response after user disable."""
success: bool
message: str
subscription_deactivated: bool = False
panel_deactivated: bool = False
user_blocked: bool = False
panel_error: str | None = None
-277
View File
@@ -1,277 +0,0 @@
"""Схемы для колеса удачи (Fortune Wheel)."""
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field
# ==================== ENUMS ====================
class WheelPaymentType(str, Enum):
"""Способы оплаты спина."""
TELEGRAM_STARS = 'telegram_stars'
SUBSCRIPTION_DAYS = 'subscription_days'
class WheelPrizeType(str, Enum):
"""Типы призов."""
SUBSCRIPTION_DAYS = 'subscription_days'
BALANCE_BONUS = 'balance_bonus'
TRAFFIC_GB = 'traffic_gb'
PROMOCODE = 'promocode'
NOTHING = 'nothing'
# ==================== USER SCHEMAS ====================
class WheelPrizeDisplay(BaseModel):
"""Отображение приза для пользователя."""
id: int
display_name: str
emoji: str
color: str
prize_type: str
class Config:
from_attributes = True
class WheelConfigResponse(BaseModel):
"""Конфигурация колеса для пользователя."""
is_enabled: bool
name: str
spin_cost_stars: int | None = None
spin_cost_days: int | None = None
spin_cost_stars_enabled: bool
spin_cost_days_enabled: bool
prizes: list[WheelPrizeDisplay]
daily_limit: int
user_spins_today: int
can_spin: bool
can_spin_reason: str | None = None
can_pay_stars: bool = False
can_pay_days: bool = False
user_balance_kopeks: int = 0
required_balance_kopeks: int = 0
class SpinAvailabilityResponse(BaseModel):
"""Доступность спина."""
can_spin: bool
reason: str | None = None
spins_remaining_today: int
can_pay_stars: bool
can_pay_days: bool
min_subscription_days: int
user_subscription_days: int
user_balance_kopeks: int = 0
required_balance_kopeks: int = 0
class SpinRequest(BaseModel):
"""Запрос на спин."""
payment_type: WheelPaymentType
class SpinResultResponse(BaseModel):
"""Результат спина."""
success: bool
prize_id: int | None = None
prize_type: str | None = None
prize_value: int = 0
prize_display_name: str = ''
emoji: str = '🎁'
color: str = '#3B82F6'
rotation_degrees: float = 0.0
message: str = ''
promocode: str | None = None
error: str | None = None
class SpinHistoryItem(BaseModel):
"""Элемент истории спинов."""
id: int
payment_type: str
payment_amount: int
prize_type: str
prize_value: int
prize_display_name: str
emoji: str = '🎁'
color: str = '#3B82F6'
prize_value_kopeks: int
created_at: datetime
class Config:
from_attributes = True
class SpinHistoryResponse(BaseModel):
"""История спинов с пагинацией."""
items: list[SpinHistoryItem]
total: int
page: int
per_page: int
pages: int
# ==================== ADMIN SCHEMAS ====================
class WheelPrizeAdminResponse(BaseModel):
"""Полная информация о призе для админа."""
id: int
config_id: int
prize_type: str
prize_value: int
display_name: str
emoji: str
color: str
prize_value_kopeks: int
sort_order: int
manual_probability: float | None = None
is_active: bool
promo_balance_bonus_kopeks: int = 0
promo_subscription_days: int = 0
promo_traffic_gb: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
class AdminWheelConfigResponse(BaseModel):
"""Полная конфигурация колеса для админа."""
id: int
is_enabled: bool
name: str
spin_cost_stars: int
spin_cost_days: int
spin_cost_stars_enabled: bool
spin_cost_days_enabled: bool
rtp_percent: int
daily_spin_limit: int
min_subscription_days_for_day_payment: int
promo_prefix: str
promo_validity_days: int
prizes: list[WheelPrizeAdminResponse]
created_at: datetime | None = None
updated_at: datetime | None = None
class Config:
from_attributes = True
class UpdateWheelConfigRequest(BaseModel):
"""Запрос на обновление конфига колеса."""
is_enabled: bool | None = None
name: str | None = Field(None, min_length=1, max_length=255)
spin_cost_stars: int | None = Field(None, ge=1, le=1000)
spin_cost_days: int | None = Field(None, ge=1, le=30)
spin_cost_stars_enabled: bool | None = None
spin_cost_days_enabled: bool | None = None
rtp_percent: int | None = Field(None, ge=0, le=100)
daily_spin_limit: int | None = Field(None, ge=0, le=100)
min_subscription_days_for_day_payment: int | None = Field(None, ge=1, le=30)
promo_prefix: str | None = Field(None, min_length=1, max_length=20)
promo_validity_days: int | None = Field(None, ge=1, le=365)
class CreatePrizeRequest(BaseModel):
"""Запрос на создание приза."""
prize_type: WheelPrizeType
prize_value: int = Field(..., ge=0)
display_name: str = Field(..., min_length=1, max_length=100)
emoji: str = Field(default='🎁', max_length=10)
color: str = Field(default='#3B82F6', pattern=r'^#[0-9A-Fa-f]{6}$')
prize_value_kopeks: int = Field(..., ge=0)
sort_order: int = Field(default=0, ge=0)
manual_probability: float | None = Field(None, ge=0, le=1)
is_active: bool = True
promo_balance_bonus_kopeks: int = Field(default=0, ge=0)
promo_subscription_days: int = Field(default=0, ge=0)
promo_traffic_gb: int = Field(default=0, ge=0)
class UpdatePrizeRequest(BaseModel):
"""Запрос на обновление приза."""
prize_type: WheelPrizeType | None = None
prize_value: int | None = Field(None, ge=0)
display_name: str | None = Field(None, min_length=1, max_length=100)
emoji: str | None = Field(None, max_length=10)
color: str | None = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
prize_value_kopeks: int | None = Field(None, ge=0)
sort_order: int | None = Field(None, ge=0)
manual_probability: float | None = Field(None, ge=0, le=1)
is_active: bool | None = None
promo_balance_bonus_kopeks: int | None = Field(None, ge=0)
promo_subscription_days: int | None = Field(None, ge=0)
promo_traffic_gb: int | None = Field(None, ge=0)
class ReorderPrizesRequest(BaseModel):
"""Запрос на переупорядочивание призов."""
prize_ids: list[int]
class AdminSpinItem(BaseModel):
"""Спин для админки."""
id: int
user_id: int
username: str | None = None
payment_type: str
payment_amount: int
payment_value_kopeks: int
prize_type: str
prize_value: int
prize_display_name: str
prize_value_kopeks: int
is_applied: bool
created_at: datetime
class Config:
from_attributes = True
class AdminSpinsResponse(BaseModel):
"""Список спинов для админки с пагинацией."""
items: list[AdminSpinItem]
total: int
page: int
per_page: int
pages: int
class WheelStatisticsResponse(BaseModel):
"""Статистика колеса."""
total_spins: int
total_revenue_kopeks: int
total_payout_kopeks: int
actual_rtp_percent: float
configured_rtp_percent: int
spins_by_payment_type: dict
prizes_distribution: list[dict]
top_wins: list[dict]
period_from: str | None = None
period_to: str | None = None
-6
View File
@@ -1,6 +0,0 @@
"""Cabinet services."""
from .email_service import EmailService, email_service
__all__ = ['EmailService', 'email_service']
-486
View File
@@ -1,486 +0,0 @@
"""Email service for sending verification and password reset emails."""
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from app.config import settings
logger = logging.getLogger(__name__)
class EmailService:
"""Service for sending emails via SMTP."""
def __init__(self):
self.host = settings.SMTP_HOST
self.port = settings.SMTP_PORT
self.user = settings.SMTP_USER
self.password = settings.SMTP_PASSWORD
self.from_email = settings.get_smtp_from_email()
self.from_name = settings.SMTP_FROM_NAME
self.use_tls = settings.SMTP_USE_TLS
def is_configured(self) -> bool:
"""Check if SMTP is properly configured."""
return settings.is_smtp_configured()
def _get_smtp_connection(self) -> smtplib.SMTP:
"""Create and return SMTP connection."""
smtp = smtplib.SMTP(self.host, self.port)
smtp.ehlo()
if self.use_tls:
smtp.starttls()
smtp.ehlo()
# Only attempt login if credentials are provided AND server supports AUTH
if self.user and self.password:
if smtp.has_extn('auth'):
smtp.login(self.user, self.password)
else:
logger.debug(f'SMTP server {self.host} does not support AUTH, skipping authentication')
return smtp
def send_email(
self,
to_email: str,
subject: str,
body_html: str,
body_text: str | None = None,
) -> bool:
"""
Send an email.
Args:
to_email: Recipient email address
subject: Email subject
body_html: HTML body content
body_text: Plain text body (optional, generated from HTML if not provided)
Returns:
True if email was sent successfully, False otherwise
"""
if not self.is_configured():
logger.warning('SMTP is not configured, cannot send email')
return False
try:
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = f'{self.from_name} <{self.from_email}>'
msg['To'] = to_email
# Plain text version
if body_text is None:
# Simple HTML to text conversion
import re
body_text = re.sub(r'<[^>]+>', '', body_html)
body_text = body_text.replace('&nbsp;', ' ')
body_text = body_text.replace('&amp;', '&')
body_text = body_text.replace('&lt;', '<')
body_text = body_text.replace('&gt;', '>')
part1 = MIMEText(body_text, 'plain', 'utf-8')
part2 = MIMEText(body_html, 'html', 'utf-8')
msg.attach(part1)
msg.attach(part2)
with self._get_smtp_connection() as smtp:
smtp.sendmail(self.from_email, to_email, msg.as_string())
logger.info(f'Email sent successfully to {to_email}')
return True
except Exception as e:
logger.error(f'Failed to send email to {to_email}: {e}')
return False
def send_verification_email(
self,
to_email: str,
verification_token: str,
verification_url: str,
username: str | None = None,
language: str = 'ru',
custom_subject: str | None = None,
custom_body_html: str | None = None,
) -> bool:
"""
Send email verification email.
Args:
to_email: Recipient email address
verification_token: Verification token
verification_url: Base URL for verification (token will be appended)
username: User's name for personalization
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template (already wrapped in base template)
Returns:
True if email was sent successfully, False otherwise
"""
if custom_subject and custom_body_html:
return self.send_email(to_email, custom_subject, custom_body_html)
full_url = f'{verification_url}?token={verification_token}'
expire_hours = settings.get_cabinet_email_verification_expire_hours()
# Localized content
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'subject': 'Подтверждение email адреса',
'intro': 'Спасибо за регистрацию! Пожалуйста, подтвердите ваш email адрес, нажав на кнопку ниже:',
'button': 'Подтвердить email',
'or_copy': 'Или скопируйте и вставьте эту ссылку в браузер:',
'expires': f'Ссылка действительна в течение {expire_hours} часов.',
'ignore': 'Если вы не создавали аккаунт, просто проигнорируйте это письмо.',
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'subject': 'Verify your email address',
'intro': 'Thank you for registering! Please verify your email address by clicking the button below:',
'button': 'Verify Email',
'or_copy': 'Or copy and paste this link in your browser:',
'expires': f'This link will expire in {expire_hours} hours.',
'ignore': "If you didn't create an account, you can safely ignore this email.",
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'subject': '验证您的邮箱地址',
'intro': '感谢您的注册!请点击下方按钮验证您的邮箱地址:',
'button': '验证邮箱',
'or_copy': '或将此链接复制并粘贴到浏览器中:',
'expires': f'此链接将在 {expire_hours} 小时后过期。',
'ignore': '如果您没有创建账户,请忽略此邮件。',
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'subject': 'Підтвердження email адреси',
'intro': 'Дякуємо за реєстрацію! Будь ласка, підтвердіть вашу email адресу, натиснувши на кнопку нижче:',
'button': 'Підтвердити email',
'or_copy': 'Або скопіюйте та вставте це посилання в браузер:',
'expires': f'Посилання дійсне протягом {expire_hours} годин.',
'ignore': 'Якщо ви не створювали акаунт, просто проігноруйте цей лист.',
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'تایید آدرس ایمیل',
'intro': 'از ثبت‌نام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
'button': 'تایید ایمیل',
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
'ignore': 'اگر شما این حساب را ایجاد نکرده‌اید، این ایمیل را نادیده بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
subject = t['subject']
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #007bff;
color: white !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class="container">
<h2>{t['greeting']}</h2>
<p>{t['intro']}</p>
<a href="{full_url}" class="button">{t['button']}</a>
<p>{t['or_copy']}</p>
<p><a href="{full_url}">{full_url}</a></p>
<p>{t['expires']}</p>
<p>{t['ignore']}</p>
<div class="footer">
<p>{t['regards']}<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body_html)
def send_password_reset_email(
self,
to_email: str,
reset_token: str,
reset_url: str,
username: str | None = None,
language: str = 'ru',
custom_subject: str | None = None,
custom_body_html: str | None = None,
) -> bool:
"""
Send password reset email.
Args:
to_email: Recipient email address
reset_token: Password reset token
reset_url: Base URL for password reset (token will be appended)
username: User's name for personalization
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template (already wrapped in base template)
Returns:
True if email was sent successfully, False otherwise
"""
if custom_subject and custom_body_html:
return self.send_email(to_email, custom_subject, custom_body_html)
full_url = f'{reset_url}?token={reset_token}'
expire_hours = settings.get_cabinet_password_reset_expire_hours()
# Localized content
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'subject': 'Сброс пароля',
'intro': 'Мы получили запрос на сброс вашего пароля. Нажмите на кнопку ниже, чтобы установить новый пароль:',
'button': 'Сбросить пароль',
'or_copy': 'Или скопируйте и вставьте эту ссылку в браузер:',
'expires': f'Ссылка действительна в течение {expire_hours} часов.',
'warning': 'Если вы не запрашивали сброс пароля, проигнорируйте это письмо или свяжитесь с поддержкой.',
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'subject': 'Reset your password',
'intro': 'We received a request to reset your password. Click the button below to set a new password:',
'button': 'Reset Password',
'or_copy': 'Or copy and paste this link in your browser:',
'expires': f'This link will expire in {expire_hours} hour(s).',
'warning': "If you didn't request a password reset, please ignore this email or contact support if you're concerned.",
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'subject': '重置您的密码',
'intro': '我们收到了重置您密码的请求。点击下方按钮设置新密码:',
'button': '重置密码',
'or_copy': '或将此链接复制并粘贴到浏览器中:',
'expires': f'此链接将在 {expire_hours} 小时后过期。',
'warning': '如果您没有请求重置密码,请忽略此邮件或联系客服。',
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'subject': 'Скидання пароля',
'intro': 'Ми отримали запит на скидання вашого пароля. Натисніть на кнопку нижче, щоб встановити новий пароль:',
'button': 'Скинути пароль',
'or_copy': 'Або скопіюйте та вставте це посилання в браузер:',
'expires': f'Посилання дійсне протягом {expire_hours} годин.',
'warning': "Якщо ви не запитували скидання пароля, проігноруйте цей лист або зв'яжіться з підтримкою.",
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'بازنشانی رمز عبور',
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
'button': 'بازنشانی رمز عبور',
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
'warning': 'اگر شما درخواست بازنشانی رمز عبور نداده‌اید، این ایمیل را نادیده بگیرید یا با پشتیبانی تماس بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
subject = t['subject']
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #dc3545;
color: white !important;
text-decoration: none;
border-radius: 5px;
margin: 20px 0;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
.warning {{ color: #dc3545; font-weight: bold; }}
</style>
</head>
<body>
<div class="container">
<h2>{t['greeting']}</h2>
<p>{t['intro']}</p>
<a href="{full_url}" class="button">{t['button']}</a>
<p>{t['or_copy']}</p>
<p><a href="{full_url}">{full_url}</a></p>
<p>{t['expires']}</p>
<p class="warning">{t['warning']}</p>
<div class="footer">
<p>{t['regards']}<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body_html)
def send_email_change_code(
self,
to_email: str,
code: str,
username: str | None = None,
language: str = 'ru',
custom_subject: str | None = None,
custom_body_html: str | None = None,
) -> bool:
"""
Send email change verification code.
Args:
to_email: New email address
code: 6-digit verification code
username: User's name for personalization
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template
Returns:
True if email was sent successfully, False otherwise
"""
if custom_subject and custom_body_html:
return self.send_email(to_email, custom_subject, custom_body_html)
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'subject': 'Код подтверждения для смены email',
'intro': 'Вы запросили смену email адреса. Используйте код ниже для подтверждения:',
'code_label': 'Ваш код подтверждения:',
'expires': f'Код действителен в течение {expire_minutes} минут.',
'ignore': 'Если вы не запрашивали смену email, просто проигнорируйте это письмо.',
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'subject': 'Email change verification code',
'intro': 'You requested to change your email address. Use the code below to confirm:',
'code_label': 'Your verification code:',
'expires': f'This code will expire in {expire_minutes} minutes.',
'ignore': "If you didn't request an email change, you can safely ignore this email.",
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'subject': '邮箱更换验证码',
'intro': '您请求更换邮箱地址。请使用以下验证码确认:',
'code_label': '您的验证码:',
'expires': f'此验证码将在 {expire_minutes} 分钟后过期。',
'ignore': '如果您没有请求更换邮箱,请忽略此邮件。',
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'subject': 'Код підтвердження для зміни email',
'intro': 'Ви запросили зміну email адреси. Використовуйте код нижче для підтвердження:',
'code_label': 'Ваш код підтвердження:',
'expires': f'Код дійсний протягом {expire_minutes} хвилин.',
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'کد تایید تغییر ایمیل',
'intro': 'شما درخواست تغییر ایمیل داده‌اید. برای تایید از کد زیر استفاده کنید:',
'code_label': 'کد تایید شما:',
'expires': f'این کد تا {expire_minutes} دقیقه معتبر است.',
'ignore': 'اگر شما درخواست تغییر ایمیل نداده‌اید، این ایمیل را نادیده بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
subject = t['subject']
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.code-box {{
background-color: #f8f9fa;
border: 2px solid #007bff;
border-radius: 8px;
padding: 20px;
text-align: center;
margin: 20px 0;
}}
.code {{
font-size: 32px;
font-weight: bold;
letter-spacing: 8px;
color: #007bff;
font-family: monospace;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class="container">
<h2>{t['greeting']}</h2>
<p>{t['intro']}</p>
<div class="code-box">
<p>{t['code_label']}</p>
<p class="code">{code}</p>
</div>
<p>{t['expires']}</p>
<p>{t['ignore']}</p>
<div class="footer">
<p>{t['regards']}<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body_html)
# Singleton instance
email_service = EmailService()
@@ -1,220 +0,0 @@
"""
Service for managing email template overrides stored in the database.
Custom templates override the hardcoded defaults from email_templates.py.
"""
import logging
from datetime import datetime
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
async def get_template_override(
notification_type: str,
language: str,
db: AsyncSession | None = None,
) -> dict[str, str] | None:
"""
Get custom email template from the database.
Returns:
Dict with 'subject' and 'body_html' if found, None otherwise.
"""
try:
if db:
result = await db.execute(
text(
'SELECT subject, body_html FROM email_templates '
'WHERE notification_type = :ntype AND language = :lang AND is_active = :active'
),
{'ntype': notification_type, 'lang': language, 'active': True},
)
row = result.fetchone()
if row:
return {'subject': row[0], 'body_html': row[1]}
return None
async with AsyncSessionLocal() as session:
result = await session.execute(
text(
'SELECT subject, body_html FROM email_templates '
'WHERE notification_type = :ntype AND language = :lang AND is_active = :active'
),
{'ntype': notification_type, 'lang': language, 'active': True},
)
row = result.fetchone()
if row:
return {'subject': row[0], 'body_html': row[1]}
return None
except Exception as e:
logger.debug('Не удалось получить override шаблона %s/%s: %s', notification_type, language, e)
return None
async def get_all_overrides(db: AsyncSession) -> list[dict[str, Any]]:
"""Get all custom template overrides from the database."""
result = await db.execute(
text(
'SELECT id, notification_type, language, subject, body_html, is_active, created_at, updated_at FROM email_templates ORDER BY notification_type, language'
)
)
rows = result.fetchall()
return [
{
'id': row[0],
'notification_type': row[1],
'language': row[2],
'subject': row[3],
'body_html': row[4],
'is_active': row[5],
'created_at': str(row[6]) if row[6] else None,
'updated_at': str(row[7]) if row[7] else None,
}
for row in rows
]
async def get_overrides_for_type(notification_type: str, db: AsyncSession) -> list[dict[str, Any]]:
"""Get all language overrides for a specific notification type."""
result = await db.execute(
text(
'SELECT id, language, subject, body_html, is_active, created_at, updated_at '
'FROM email_templates WHERE notification_type = :ntype ORDER BY language'
),
{'ntype': notification_type},
)
rows = result.fetchall()
return [
{
'id': row[0],
'language': row[1],
'subject': row[2],
'body_html': row[3],
'is_active': row[4],
'created_at': str(row[5]) if row[5] else None,
'updated_at': str(row[6]) if row[6] else None,
}
for row in rows
]
async def save_template_override(
notification_type: str,
language: str,
subject: str,
body_html: str,
db: AsyncSession,
) -> dict[str, Any]:
"""Save or update a custom email template in the database."""
# Check if exists
existing = await db.execute(
text('SELECT id FROM email_templates WHERE notification_type = :ntype AND language = :lang'),
{'ntype': notification_type, 'lang': language},
)
row = existing.fetchone()
now = datetime.utcnow()
if row:
# Update
await db.execute(
text(
'UPDATE email_templates SET subject = :subject, body_html = :body_html, '
'is_active = :active, updated_at = :now '
'WHERE notification_type = :ntype AND language = :lang'
),
{
'subject': subject,
'body_html': body_html,
'active': True,
'now': now,
'ntype': notification_type,
'lang': language,
},
)
else:
# Insert
await db.execute(
text(
'INSERT INTO email_templates (notification_type, language, subject, body_html, is_active, created_at, updated_at) '
'VALUES (:ntype, :lang, :subject, :body_html, :active, :now, :now)'
),
{
'ntype': notification_type,
'lang': language,
'subject': subject,
'body_html': body_html,
'active': True,
'now': now,
},
)
await db.commit()
return {
'notification_type': notification_type,
'language': language,
'subject': subject,
'body_html': body_html,
'is_active': True,
}
async def get_rendered_override(
notification_type: str,
language: str,
context: dict[str, Any] | None = None,
db: AsyncSession | None = None,
) -> tuple[str, str] | None:
"""
Get a custom template override rendered with the base email template.
Returns:
Tuple of (subject, body_html) if override exists, None otherwise.
"""
override = await get_template_override(notification_type, language, db)
if not override:
return None
from .email_templates import EmailNotificationTemplates
templates = EmailNotificationTemplates()
body_html = override['body_html']
# Simple variable substitution for context vars like {username}, {verification_url}, etc.
if context:
for key, value in context.items():
body_html = body_html.replace(f'{{{key}}}', str(value))
rendered = templates._get_base_template(body_html, language)
subject = override['subject']
# Also substitute in subject
if context:
for key, value in context.items():
subject = subject.replace(f'{{{key}}}', str(value))
return (subject, rendered)
async def delete_template_override(
notification_type: str,
language: str,
db: AsyncSession,
) -> bool:
"""Delete a custom template override (revert to default)."""
result = await db.execute(
text('DELETE FROM email_templates WHERE notification_type = :ntype AND language = :lang'),
{'ntype': notification_type, 'lang': language},
)
await db.commit()
return result.rowcount > 0
File diff suppressed because it is too large Load Diff
+669 -1697
View File
File diff suppressed because it is too large Load Diff
+8 -9
View File
@@ -11,14 +11,13 @@ from .database import (
init_db,
)
__all__ = [
'DatabaseManager',
'batch_ops',
'close_db',
'db_manager',
'get_db',
'get_db_read_only',
'get_pool_metrics',
'init_db',
"DatabaseManager",
"batch_ops",
"close_db",
"db_manager",
"get_db",
"get_db_read_only",
"get_pool_metrics",
"init_db",
]
+98 -82
View File
@@ -1,7 +1,8 @@
import logging
from datetime import datetime
from typing import Dict, List, Optional
from sqlalchemy import and_, delete, func, select, update
from sqlalchemy import and_, func, select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -16,7 +17,6 @@ from app.database.models import (
User,
)
logger = logging.getLogger(__name__)
@@ -26,15 +26,12 @@ async def create_campaign(
name: str,
start_parameter: str,
bonus_type: str,
created_by: int | None = None,
created_by: Optional[int] = None,
balance_bonus_kopeks: int = 0,
subscription_duration_days: int | None = None,
subscription_traffic_gb: int | None = None,
subscription_device_limit: int | None = None,
subscription_squads: list[str] | None = None,
# Поля для типа "tariff"
tariff_id: int | None = None,
tariff_duration_days: int | None = None,
subscription_duration_days: Optional[int] = None,
subscription_traffic_gb: Optional[int] = None,
subscription_device_limit: Optional[int] = None,
subscription_squads: Optional[List[str]] = None,
is_active: bool = True,
) -> AdvertisingCampaign:
campaign = AdvertisingCampaign(
@@ -46,8 +43,6 @@ async def create_campaign(
subscription_traffic_gb=subscription_traffic_gb,
subscription_device_limit=subscription_device_limit,
subscription_squads=subscription_squads or [],
tariff_id=tariff_id,
tariff_duration_days=tariff_duration_days,
created_by=created_by,
is_active=is_active,
)
@@ -57,7 +52,7 @@ async def create_campaign(
await db.refresh(campaign)
logger.info(
'📣 Создана рекламная кампания %s (start=%s, bonus=%s)',
"📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
campaign.name,
campaign.start_parameter,
campaign.bonus_type,
@@ -65,13 +60,12 @@ async def create_campaign(
return campaign
async def get_campaign_by_id(db: AsyncSession, campaign_id: int) -> AdvertisingCampaign | None:
async def get_campaign_by_id(
db: AsyncSession, campaign_id: int
) -> Optional[AdvertisingCampaign]:
result = await db.execute(
select(AdvertisingCampaign)
.options(
selectinload(AdvertisingCampaign.registrations),
selectinload(AdvertisingCampaign.tariff),
)
.options(selectinload(AdvertisingCampaign.registrations))
.where(AdvertisingCampaign.id == campaign_id)
)
return result.scalar_one_or_none()
@@ -82,8 +76,10 @@ async def get_campaign_by_start_parameter(
start_parameter: str,
*,
only_active: bool = False,
) -> AdvertisingCampaign | None:
stmt = select(AdvertisingCampaign).where(AdvertisingCampaign.start_parameter == start_parameter)
) -> Optional[AdvertisingCampaign]:
stmt = select(AdvertisingCampaign).where(
AdvertisingCampaign.start_parameter == start_parameter
)
if only_active:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
@@ -97,13 +93,10 @@ async def get_campaigns_list(
offset: int = 0,
limit: int = 20,
include_inactive: bool = True,
) -> list[AdvertisingCampaign]:
) -> List[AdvertisingCampaign]:
stmt = (
select(AdvertisingCampaign)
.options(
selectinload(AdvertisingCampaign.registrations),
selectinload(AdvertisingCampaign.tariff),
)
.options(selectinload(AdvertisingCampaign.registrations))
.order_by(AdvertisingCampaign.created_at.desc())
.offset(offset)
.limit(limit)
@@ -115,7 +108,9 @@ async def get_campaigns_list(
return result.scalars().all()
async def get_campaigns_count(db: AsyncSession, *, is_active: bool | None = None) -> int:
async def get_campaigns_count(
db: AsyncSession, *, is_active: Optional[bool] = None
) -> int:
stmt = select(func.count(AdvertisingCampaign.id))
if is_active is not None:
stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active))
@@ -130,17 +125,15 @@ async def update_campaign(
**kwargs,
) -> AdvertisingCampaign:
allowed_fields = {
'name',
'start_parameter',
'bonus_type',
'balance_bonus_kopeks',
'subscription_duration_days',
'subscription_traffic_gb',
'subscription_device_limit',
'subscription_squads',
'tariff_id',
'tariff_duration_days',
'is_active',
"name",
"start_parameter",
"bonus_type",
"balance_bonus_kopeks",
"subscription_duration_days",
"subscription_traffic_gb",
"subscription_device_limit",
"subscription_squads",
"is_active",
}
update_data = {}
@@ -151,27 +144,33 @@ async def update_campaign(
if not update_data:
return campaign
update_data['updated_at'] = datetime.utcnow()
update_data["updated_at"] = datetime.utcnow()
await db.execute(update(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id).values(**update_data))
await db.execute(
update(AdvertisingCampaign)
.where(AdvertisingCampaign.id == campaign.id)
.values(**update_data)
)
await db.commit()
await db.refresh(campaign)
logger.info('✏️ Обновлена рекламная кампания %s (%s)', campaign.name, update_data)
logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data)
return campaign
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
await db.execute(delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id))
await db.execute(
delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)
)
await db.commit()
logger.info('🗑️ Удалена рекламная кампания %s', campaign.name)
logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
return True
async def get_campaign_registration_by_user(
db: AsyncSession,
user_id: int,
) -> AdvertisingCampaignRegistration | None:
) -> Optional[AdvertisingCampaignRegistration]:
result = await db.execute(
select(AdvertisingCampaignRegistration)
.options(selectinload(AdvertisingCampaignRegistration.campaign))
@@ -188,9 +187,7 @@ async def record_campaign_registration(
user_id: int,
bonus_type: str,
balance_bonus_kopeks: int = 0,
subscription_duration_days: int | None = None,
tariff_id: int | None = None,
tariff_duration_days: int | None = None,
subscription_duration_days: Optional[int] = None,
) -> AdvertisingCampaignRegistration:
existing = await db.execute(
select(AdvertisingCampaignRegistration).where(
@@ -210,21 +207,19 @@ async def record_campaign_registration(
bonus_type=bonus_type,
balance_bonus_kopeks=balance_bonus_kopeks or 0,
subscription_duration_days=subscription_duration_days,
tariff_id=tariff_id,
tariff_duration_days=tariff_duration_days,
)
db.add(registration)
await db.commit()
await db.refresh(registration)
logger.info('📈 Регистрируем пользователя %s в кампании %s', user_id, campaign_id)
logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
return registration
async def get_campaign_statistics(
db: AsyncSession,
campaign_id: int,
) -> dict[str, int | None]:
) -> Dict[str, Optional[int]]:
registrations_query = select(AdvertisingCampaignRegistration.user_id).where(
AdvertisingCampaignRegistration.campaign_id == campaign_id
)
@@ -233,7 +228,9 @@ async def get_campaign_statistics(
result = await db.execute(
select(
func.count(AdvertisingCampaignRegistration.id),
func.coalesce(func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0),
func.coalesce(
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
),
func.max(AdvertisingCampaignRegistration.created_at),
).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
)
@@ -245,7 +242,7 @@ async def get_campaign_statistics(
select(func.count(AdvertisingCampaignRegistration.id)).where(
and_(
AdvertisingCampaignRegistration.campaign_id == campaign_id,
AdvertisingCampaignRegistration.bonus_type == 'subscription',
AdvertisingCampaignRegistration.bonus_type == "subscription",
)
)
)
@@ -298,7 +295,11 @@ async def get_campaign_statistics(
SubscriptionConversion.first_payment_amount_kopeks,
SubscriptionConversion.converted_at,
)
.where(SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id)))
.where(
SubscriptionConversion.user_id.in_(
select(registrations_subquery.c.user_id)
)
)
.order_by(SubscriptionConversion.converted_at)
)
conversion_entries = conversions_rows.all()
@@ -321,8 +322,8 @@ async def get_campaign_statistics(
subscription_payments_total = 0
paid_users_from_transactions = set()
conversion_user_ids = set()
first_payment_amount_by_user: dict[int, int] = {}
first_payment_time_by_user: dict[int, datetime | None] = {}
first_payment_amount_by_user: Dict[int, int] = {}
first_payment_time_by_user: Dict[int, Optional[datetime]] = {}
for user_id, amount_kopeks, converted_at in conversion_entries:
conversion_user_ids.add(user_id)
@@ -340,8 +341,13 @@ async def get_campaign_statistics(
first_payment_time_by_user[user_id] = created_at
else:
existing_time = first_payment_time_by_user.get(user_id)
if (existing_time is None and created_at is not None) or (
existing_time is not None and created_at is not None and created_at < existing_time
if existing_time is None and created_at is not None:
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
elif (
existing_time is not None
and created_at is not None
and created_at < existing_time
):
first_payment_amount_by_user[user_id] = amount_value
first_payment_time_by_user[user_id] = created_at
@@ -353,11 +359,15 @@ async def get_campaign_statistics(
paid_users_count = max(len(paid_user_ids), paid_users_from_flag)
conversion_count = conversion_count or len(paid_user_ids)
conversion_count = max(conversion_count, len(paid_user_ids))
if conversion_count < len(paid_user_ids):
conversion_count = len(paid_user_ids)
avg_first_payment = 0
if first_payment_amount_by_user:
avg_first_payment = int(sum(first_payment_amount_by_user.values()) / len(first_payment_amount_by_user))
avg_first_payment = int(
sum(first_payment_amount_by_user.values())
/ len(first_payment_amount_by_user)
)
conversion_rate = 0.0
if count:
@@ -432,44 +442,50 @@ async def get_campaign_statistics(
avg_revenue_per_user = int(total_revenue / count)
return {
'registrations': count,
'balance_issued': total_balance,
'subscription_issued': subscription_bonuses_issued,
'last_registration': last_registration,
'total_revenue_kopeks': total_revenue,
'trial_users_count': trial_users_count,
'active_trials_count': active_trials_count,
'conversion_count': conversion_count,
'paid_users_count': paid_users_count,
'conversion_rate': conversion_rate,
'trial_conversion_rate': trial_conversion_rate,
'avg_revenue_per_user_kopeks': avg_revenue_per_user,
'avg_first_payment_kopeks': avg_first_payment,
"registrations": count,
"balance_issued": total_balance,
"subscription_issued": subscription_bonuses_issued,
"last_registration": last_registration,
"total_revenue_kopeks": total_revenue,
"trial_users_count": trial_users_count,
"active_trials_count": active_trials_count,
"conversion_count": conversion_count,
"paid_users_count": paid_users_count,
"conversion_rate": conversion_rate,
"trial_conversion_rate": trial_conversion_rate,
"avg_revenue_per_user_kopeks": avg_revenue_per_user,
"avg_first_payment_kopeks": avg_first_payment,
}
async def get_campaigns_overview(db: AsyncSession) -> dict[str, int]:
async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]:
total = await get_campaigns_count(db)
active = await get_campaigns_count(db, is_active=True)
inactive = await get_campaigns_count(db, is_active=False)
registrations_result = await db.execute(select(func.count(AdvertisingCampaignRegistration.id)))
registrations_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id))
)
balance_result = await db.execute(
select(func.coalesce(func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0))
select(
func.coalesce(
func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
)
)
)
subscription_result = await db.execute(
select(func.count(AdvertisingCampaignRegistration.id)).where(
AdvertisingCampaignRegistration.bonus_type == 'subscription'
AdvertisingCampaignRegistration.bonus_type == "subscription"
)
)
return {
'total': total,
'active': active,
'inactive': inactive,
'registrations': registrations_result.scalar() or 0,
'balance_total': balance_result.scalar() or 0,
'subscription_total': subscription_result.scalar() or 0,
"total": total,
"active": active,
"inactive": inactive,
"registrations": registrations_result.scalar() or 0,
"balance_total": balance_result.scalar() or 0,
"subscription_total": subscription_result.scalar() or 0,
}
-238
View File
@@ -1,238 +0,0 @@
"""CRUD operations for CloudPayments payments."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import CloudPaymentsPayment
logger = logging.getLogger(__name__)
async def create_cloudpayments_payment(
db: AsyncSession,
*,
user_id: int,
invoice_id: str,
amount_kopeks: int,
description: str | None = None,
currency: str = 'RUB',
payment_url: str | None = None,
email: str | None = None,
metadata: dict[str, Any] | None = None,
test_mode: bool = False,
) -> CloudPaymentsPayment:
"""
Create a new CloudPayments payment record.
Args:
db: Database session
user_id: Internal user ID
invoice_id: Unique invoice ID
amount_kopeks: Amount in kopeks
description: Payment description
currency: Currency code (default RUB)
payment_url: Payment widget URL
email: User's email
metadata: Additional metadata
test_mode: Whether this is a test payment
Returns:
Created CloudPaymentsPayment object
"""
payment = CloudPaymentsPayment(
user_id=user_id,
invoice_id=invoice_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status='pending',
is_paid=False,
payment_url=payment_url,
email=email,
metadata_json=metadata,
test_mode=test_mode,
)
db.add(payment)
await db.flush()
await db.refresh(payment)
logger.debug(
'Created CloudPayments payment: id=%s, invoice=%s, amount=%s',
payment.id,
invoice_id,
amount_kopeks,
)
return payment
async def get_cloudpayments_payment_by_invoice_id(
db: AsyncSession,
invoice_id: str,
) -> CloudPaymentsPayment | None:
"""Get CloudPayments payment by invoice ID."""
result = await db.execute(select(CloudPaymentsPayment).where(CloudPaymentsPayment.invoice_id == invoice_id))
return result.scalars().first()
async def get_cloudpayments_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> CloudPaymentsPayment | None:
"""Get CloudPayments payment by internal ID."""
result = await db.execute(select(CloudPaymentsPayment).where(CloudPaymentsPayment.id == payment_id))
return result.scalars().first()
async def get_cloudpayments_payment_by_transaction_id(
db: AsyncSession,
transaction_id_cp: int,
) -> CloudPaymentsPayment | None:
"""Get CloudPayments payment by CloudPayments transaction ID."""
result = await db.execute(
select(CloudPaymentsPayment).where(CloudPaymentsPayment.transaction_id_cp == transaction_id_cp)
)
return result.scalars().first()
async def update_cloudpayments_payment(
db: AsyncSession,
payment_id: int,
**kwargs: Any,
) -> CloudPaymentsPayment | None:
"""
Update CloudPayments payment record.
Args:
db: Database session
payment_id: Internal payment ID
**kwargs: Fields to update
Returns:
Updated payment or None if not found
"""
payment = await get_cloudpayments_payment_by_id(db, payment_id)
if not payment:
return None
for key, value in kwargs.items():
if hasattr(payment, key):
setattr(payment, key, value)
payment.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(payment)
return payment
async def mark_cloudpayments_payment_as_paid(
db: AsyncSession,
payment_id: int,
*,
transaction_id_cp: int | None = None,
token: str | None = None,
card_first_six: str | None = None,
card_last_four: str | None = None,
card_type: str | None = None,
card_exp_date: str | None = None,
email: str | None = None,
callback_payload: dict[str, Any] | None = None,
) -> CloudPaymentsPayment | None:
"""
Mark CloudPayments payment as paid.
Args:
db: Database session
payment_id: Internal payment ID
transaction_id_cp: CloudPayments transaction ID
token: Card token for recurrent payments
card_first_six: First 6 digits of card
card_last_four: Last 4 digits of card
card_type: Card type (Visa, MasterCard, etc.)
card_exp_date: Card expiration date
email: Payer's email
callback_payload: Full webhook payload
Returns:
Updated payment or None if not found
"""
payment = await get_cloudpayments_payment_by_id(db, payment_id)
if not payment:
return None
payment.status = 'completed'
payment.is_paid = True
payment.paid_at = datetime.utcnow()
if transaction_id_cp is not None:
payment.transaction_id_cp = transaction_id_cp
if token:
payment.token = token
if card_first_six:
payment.card_first_six = card_first_six
if card_last_four:
payment.card_last_four = card_last_four
if card_type:
payment.card_type = card_type
if card_exp_date:
payment.card_exp_date = card_exp_date
if email:
payment.email = email
if callback_payload:
payment.callback_payload = callback_payload
payment.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(payment)
logger.info(
'Marked CloudPayments payment as paid: id=%s, invoice=%s',
payment.id,
payment.invoice_id,
)
return payment
async def link_cloudpayments_payment_to_transaction(
db: AsyncSession,
payment_id: int,
transaction_id: int,
) -> CloudPaymentsPayment | None:
"""Link CloudPayments payment to internal transaction."""
payment = await get_cloudpayments_payment_by_id(db, payment_id)
if not payment:
return None
payment.transaction_id = transaction_id
await db.flush()
await db.refresh(payment)
return payment
async def get_user_cloudpayments_payments(
db: AsyncSession,
user_id: int,
*,
limit: int = 10,
offset: int = 0,
) -> list[CloudPaymentsPayment]:
"""Get CloudPayments payments for a user."""
result = await db.execute(
select(CloudPaymentsPayment)
.where(CloudPaymentsPayment.user_id == user_id)
.order_by(CloudPaymentsPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
-225
View File
@@ -1,225 +0,0 @@
import logging
from collections.abc import Sequence
from datetime import datetime
from sqlalchemy import and_, delete, desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import ContestAttempt, ContestRound, ContestTemplate, User
logger = logging.getLogger(__name__)
# Templates
async def get_template_by_id(db: AsyncSession, template_id: int) -> ContestTemplate | None:
result = await db.execute(select(ContestTemplate).where(ContestTemplate.id == template_id))
return result.scalar_one_or_none()
async def get_template_by_slug(db: AsyncSession, slug: str) -> ContestTemplate | None:
result = await db.execute(select(ContestTemplate).where(ContestTemplate.slug == slug))
return result.scalar_one_or_none()
async def list_templates(db: AsyncSession, enabled_only: bool = True) -> list[ContestTemplate]:
query = select(ContestTemplate).order_by(ContestTemplate.id)
if enabled_only:
query = query.where(ContestTemplate.is_enabled.is_(True))
result = await db.execute(query)
return list(result.scalars().all())
async def upsert_template(
db: AsyncSession,
*,
slug: str,
name: str,
description: str = '',
prize_type: str = 'days',
prize_value: str = '1',
max_winners: int = 1,
attempts_per_user: int = 1,
times_per_day: int = 1,
schedule_times: str | None = None,
cooldown_hours: int = 24,
payload: dict | None = None,
is_enabled: bool | None = None,
) -> ContestTemplate:
template = await get_template_by_slug(db, slug)
if not template:
template = ContestTemplate(slug=slug)
db.add(template)
template.name = name
template.description = description
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
template.schedule_times = schedule_times
template.cooldown_hours = cooldown_hours
template.payload = payload or {}
if is_enabled is not None:
template.is_enabled = is_enabled
await db.commit()
await db.refresh(template)
return template
async def update_template_fields(
db: AsyncSession,
template: ContestTemplate,
**fields: object,
) -> ContestTemplate:
for key, value in fields.items():
if hasattr(template, key):
setattr(template, key, value)
await db.commit()
await db.refresh(template)
return template
# Rounds
async def create_round(
db: AsyncSession,
*,
template: ContestTemplate,
starts_at: datetime,
ends_at: datetime,
payload: dict,
) -> ContestRound:
round_obj = ContestRound(
template_id=template.id,
starts_at=starts_at,
ends_at=ends_at,
status='active',
payload=payload,
max_winners=template.max_winners,
attempts_per_user=template.attempts_per_user,
)
db.add(round_obj)
await db.commit()
await db.refresh(round_obj)
return round_obj
async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
now = datetime.utcnow()
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(
and_(
ContestRound.status == 'active',
ContestRound.starts_at <= now,
ContestRound.ends_at >= now,
)
)
.order_by(ContestRound.starts_at)
)
return list(result.scalars().all())
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> ContestRound | None:
now = datetime.utcnow()
result = await db.execute(
select(ContestRound)
.options(selectinload(ContestRound.template))
.where(
and_(
ContestRound.template_id == template_id,
ContestRound.status == 'active',
ContestRound.starts_at <= now,
ContestRound.ends_at >= now,
)
)
.order_by(desc(ContestRound.starts_at))
)
return result.scalars().first()
async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
round_obj.status = 'finished'
await db.commit()
await db.refresh(round_obj)
return round_obj
async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> ContestRound:
round_obj.winners_count += 1
await db.commit()
await db.refresh(round_obj)
return round_obj
# Attempts
async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> ContestAttempt | None:
result = await db.execute(
select(ContestAttempt).where(
and_(
ContestAttempt.round_id == round_id,
ContestAttempt.user_id == user_id,
)
)
)
return result.scalar_one_or_none()
async def create_attempt(
db: AsyncSession,
*,
round_id: int,
user_id: int,
answer: str | None,
is_winner: bool,
) -> ContestAttempt:
attempt = ContestAttempt(
round_id=round_id,
user_id=user_id,
answer=answer,
is_winner=is_winner,
)
db.add(attempt)
await db.commit()
await db.refresh(attempt)
return attempt
async def update_attempt(
db: AsyncSession,
attempt: ContestAttempt,
*,
answer: str | None = 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
await db.commit()
return deleted_count
async def list_winners(db: AsyncSession, round_id: int) -> Sequence[tuple[User, ContestAttempt]]:
result = await db.execute(
select(User, ContestAttempt)
.join(ContestAttempt, ContestAttempt.user_id == User.id)
.where(
and_(
ContestAttempt.round_id == round_id,
ContestAttempt.is_winner.is_(True),
)
)
)
return result.all()
+67 -38
View File
@@ -1,13 +1,12 @@
import logging
from datetime import datetime
from sqlalchemy import and_, select
from typing import Optional, List
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import CryptoBotPayment
logger = logging.getLogger(__name__)
@@ -17,13 +16,14 @@ async def create_cryptobot_payment(
invoice_id: str,
amount: str,
asset: str,
status: str = 'active',
description: str | None = None,
payload: str | None = None,
bot_invoice_url: str | None = None,
mini_app_invoice_url: str | None = None,
web_app_invoice_url: str | None = None,
status: str = "active",
description: Optional[str] = None,
payload: Optional[str] = None,
bot_invoice_url: Optional[str] = None,
mini_app_invoice_url: Optional[str] = None,
web_app_invoice_url: Optional[str] = None
) -> CryptoBotPayment:
payment = CryptoBotPayment(
user_id=user_id,
invoice_id=invoice_id,
@@ -34,18 +34,22 @@ async def create_cryptobot_payment(
payload=payload,
bot_invoice_url=bot_invoice_url,
mini_app_invoice_url=mini_app_invoice_url,
web_app_invoice_url=web_app_invoice_url,
web_app_invoice_url=web_app_invoice_url
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f'Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}')
logger.info(f"Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}")
return payment
async def get_cryptobot_payment_by_invoice_id(db: AsyncSession, invoice_id: str) -> CryptoBotPayment | None:
async def get_cryptobot_payment_by_invoice_id(
db: AsyncSession,
invoice_id: str
) -> Optional[CryptoBotPayment]:
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
@@ -54,55 +58,72 @@ async def get_cryptobot_payment_by_invoice_id(db: AsyncSession, invoice_id: str)
return result.scalar_one_or_none()
async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> CryptoBotPayment | None:
async def get_cryptobot_payment_by_id(
db: AsyncSession,
payment_id: int
) -> Optional[CryptoBotPayment]:
result = await db.execute(
select(CryptoBotPayment).options(selectinload(CryptoBotPayment.user)).where(CryptoBotPayment.id == payment_id)
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(CryptoBotPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_cryptobot_payment_status(
db: AsyncSession, invoice_id: str, status: str, paid_at: datetime | None = None
) -> CryptoBotPayment | None:
db: AsyncSession,
invoice_id: str,
status: str,
paid_at: Optional[datetime] = None
) -> Optional[CryptoBotPayment]:
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
if not payment:
return None
payment.status = status
payment.updated_at = datetime.utcnow()
if status == 'paid' and paid_at:
if status == "paid" and paid_at:
payment.paid_at = paid_at
await db.commit()
await db.refresh(payment)
logger.info(f'Обновлен статус CryptoBot платежа {invoice_id}: {status}')
logger.info(f"Обновлен статус CryptoBot платежа {invoice_id}: {status}")
return payment
async def link_cryptobot_payment_to_transaction(
db: AsyncSession, invoice_id: str, transaction_id: int
) -> CryptoBotPayment | None:
db: AsyncSession,
invoice_id: str,
transaction_id: int
) -> Optional[CryptoBotPayment]:
payment = await get_cryptobot_payment_by_invoice_id(db, invoice_id)
if not payment:
return None
payment.transaction_id = transaction_id
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
logger.info(f'Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}')
logger.info(f"Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}")
return payment
async def get_user_cryptobot_payments(
db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0
) -> list[CryptoBotPayment]:
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[CryptoBotPayment]:
result = await db.execute(
select(CryptoBotPayment)
.where(CryptoBotPayment.user_id == user_id)
@@ -113,15 +134,23 @@ async def get_user_cryptobot_payments(
return result.scalars().all()
async def get_pending_cryptobot_payments(db: AsyncSession, older_than_hours: int = 24) -> list[CryptoBotPayment]:
async def get_pending_cryptobot_payments(
db: AsyncSession,
older_than_hours: int = 24
) -> List[CryptoBotPayment]:
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(CryptoBotPayment)
.options(selectinload(CryptoBotPayment.user))
.where(and_(CryptoBotPayment.status == 'active', CryptoBotPayment.created_at < cutoff_time))
.where(
and_(
CryptoBotPayment.status == "active",
CryptoBotPayment.created_at < cutoff_time
)
)
.order_by(CryptoBotPayment.created_at)
)
return result.scalars().all()
+38 -38
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import List, Optional
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,7 +11,6 @@ from sqlalchemy.orm import selectinload
from app.database.crud.promo_offer_log import log_promo_offer_action
from app.database.models import DiscountOffer
logger = logging.getLogger(__name__)
@@ -18,13 +18,13 @@ async def upsert_discount_offer(
db: AsyncSession,
*,
user_id: int,
subscription_id: int | None,
subscription_id: Optional[int],
notification_type: str,
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
effect_type: str = 'percent_discount',
extra_data: dict | None = None,
effect_type: str = "percent_discount",
extra_data: Optional[dict] = None,
) -> DiscountOffer:
"""Create or refresh a discount offer for a user."""
@@ -35,7 +35,7 @@ async def upsert_discount_offer(
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.notification_type == notification_type,
DiscountOffer.is_active == True,
DiscountOffer.is_active == True, # noqa: E712
)
.order_by(DiscountOffer.created_at.desc())
)
@@ -67,7 +67,7 @@ async def upsert_discount_offer(
return offer
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> DiscountOffer | None:
async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]:
result = await db.execute(
select(DiscountOffer)
.options(
@@ -84,10 +84,10 @@ async def list_discount_offers(
*,
offset: int = 0,
limit: int = 50,
user_id: int | None = None,
notification_type: str | None = None,
is_active: bool | None = None,
) -> list[DiscountOffer]:
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> List[DiscountOffer]:
stmt = (
select(DiscountOffer)
.options(
@@ -113,7 +113,7 @@ async def list_discount_offers(
async def list_active_discount_offers_for_user(
db: AsyncSession,
user_id: int,
) -> list[DiscountOffer]:
) -> List[DiscountOffer]:
"""Return active (not yet claimed) offers for a user."""
now = datetime.utcnow()
@@ -125,7 +125,7 @@ async def list_active_discount_offers_for_user(
)
.where(
DiscountOffer.user_id == user_id,
DiscountOffer.is_active == True,
DiscountOffer.is_active == True, # noqa: E712
DiscountOffer.expires_at > now,
)
.order_by(DiscountOffer.expires_at.asc())
@@ -138,9 +138,9 @@ async def list_active_discount_offers_for_user(
async def count_discount_offers(
db: AsyncSession,
*,
user_id: int | None = None,
notification_type: str | None = None,
is_active: bool | None = None,
user_id: Optional[int] = None,
notification_type: Optional[str] = None,
is_active: Optional[bool] = None,
) -> int:
stmt = select(func.count(DiscountOffer.id))
@@ -159,7 +159,7 @@ async def mark_offer_claimed(
db: AsyncSession,
offer: DiscountOffer,
*,
details: dict | None = None,
details: Optional[dict] = None,
) -> DiscountOffer:
offer.claimed_at = datetime.utcnow()
offer.is_active = False
@@ -171,7 +171,7 @@ async def mark_offer_claimed(
db,
user_id=offer.user_id,
offer_id=offer.id,
action='claimed',
action="claimed",
source=offer.notification_type,
percent=offer.discount_percent,
effect_type=offer.effect_type,
@@ -179,7 +179,7 @@ async def mark_offer_claimed(
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer claim log for offer %s: %s',
"Failed to record promo offer claim log for offer %s: %s",
offer.id,
exc,
)
@@ -187,7 +187,7 @@ async def mark_offer_claimed(
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer claim log failure: %s',
"Failed to rollback session after promo offer claim log failure: %s",
rollback_error,
)
@@ -198,7 +198,7 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
now = datetime.utcnow()
result = await db.execute(
select(DiscountOffer).where(
DiscountOffer.is_active == True,
DiscountOffer.is_active == True, # noqa: E712
DiscountOffer.expires_at < now,
)
)
@@ -213,41 +213,41 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
count += 1
log_payloads.append(
{
'user_id': offer.user_id,
'offer_id': offer.id,
'source': offer.notification_type,
'percent': offer.discount_percent,
'effect_type': offer.effect_type,
"user_id": offer.user_id,
"offer_id": offer.id,
"source": offer.notification_type,
"percent": offer.discount_percent,
"effect_type": offer.effect_type,
}
)
await db.commit()
for payload in log_payloads:
if not payload.get('user_id'):
if not payload.get("user_id"):
continue
try:
await log_promo_offer_action(
db,
user_id=payload['user_id'],
offer_id=payload['offer_id'],
action='disabled',
source=payload.get('source'),
percent=payload.get('percent'),
effect_type=payload.get('effect_type'),
details={'reason': 'offer_expired'},
user_id=payload["user_id"],
offer_id=payload["offer_id"],
action="disabled",
source=payload.get("source"),
percent=payload.get("percent"),
effect_type=payload.get("effect_type"),
details={"reason": "offer_expired"},
)
except Exception as exc: # pragma: no cover - defensive logging
logger.warning(
'Failed to record promo offer disable log for offer %s: %s',
payload.get('offer_id'),
"Failed to record promo offer disable log for offer %s: %s",
payload.get("offer_id"),
exc,
)
try:
await db.rollback()
except Exception as rollback_error: # pragma: no cover - defensive logging
logger.warning(
'Failed to rollback session after promo offer disable log failure: %s',
"Failed to rollback session after promo offer disable log failure: %s",
rollback_error,
)
@@ -257,8 +257,8 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
async def get_latest_claimed_offer_for_user(
db: AsyncSession,
user_id: int,
source: str | None = None,
) -> DiscountOffer | None:
source: Optional[str] = None,
) -> Optional[DiscountOffer]:
stmt = (
select(DiscountOffer)
.where(
+23 -17
View File
@@ -1,18 +1,19 @@
import logging
from collections.abc import Iterable
from datetime import datetime
from typing import Iterable, Optional
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FaqPage, FaqSetting
logger = logging.getLogger(__name__)
async def get_faq_setting(db: AsyncSession, language: str) -> FaqSetting | None:
result = await db.execute(select(FaqSetting).where(FaqSetting.language == language))
async def get_faq_setting(db: AsyncSession, language: str) -> Optional[FaqSetting]:
result = await db.execute(
select(FaqSetting).where(FaqSetting.language == language)
)
return result.scalar_one_or_none()
@@ -33,9 +34,9 @@ async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> Faq
await db.refresh(setting)
logger.info(
'✅ Статус FAQ для языка %s обновлен: %s',
"✅ Статус FAQ для языка %s обновлен: %s",
language,
'enabled' if setting.is_enabled else 'disabled',
"enabled" if setting.is_enabled else "disabled",
)
return setting
@@ -63,7 +64,7 @@ async def get_faq_pages(
return pages
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> FaqPage | None:
async def get_faq_page_by_id(db: AsyncSession, page_id: int) -> Optional[FaqPage]:
result = await db.execute(select(FaqPage).where(FaqPage.id == page_id))
return result.scalar_one_or_none()
@@ -74,11 +75,13 @@ async def create_faq_page(
language: str,
title: str,
content: str,
display_order: int | None = None,
display_order: Optional[int] = None,
is_active: bool = True,
) -> FaqPage:
if display_order is None:
result = await db.execute(select(func.max(FaqPage.display_order)).where(FaqPage.language == language))
result = await db.execute(
select(func.max(FaqPage.display_order)).where(FaqPage.language == language)
)
max_order = result.scalar() or 0
display_order = max_order + 1
@@ -94,7 +97,7 @@ async def create_faq_page(
await db.commit()
await db.refresh(page)
logger.info('✅ Создана страница FAQ %s для языка %s', page.id, language)
logger.info("✅ Создана страница FAQ %s для языка %s", page.id, language)
return page
@@ -103,10 +106,10 @@ async def update_faq_page(
db: AsyncSession,
page: FaqPage,
*,
title: str | None = None,
content: str | None = None,
display_order: int | None = None,
is_active: bool | None = None,
title: Optional[str] = None,
content: Optional[str] = None,
display_order: Optional[int] = None,
is_active: Optional[bool] = None,
) -> FaqPage:
if title is not None:
page.title = title
@@ -122,7 +125,7 @@ async def update_faq_page(
await db.commit()
await db.refresh(page)
logger.info('✅ Страница FAQ %s обновлена', page.id)
logger.info("✅ Страница FAQ %s обновлена", page.id)
return page
@@ -130,7 +133,7 @@ async def update_faq_page(
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
await db.commit()
logger.info('🗑️ Страница FAQ %s удалена', page_id)
logger.info("🗑️ Страница FAQ %s удалена", page_id)
async def bulk_update_order(
@@ -139,6 +142,9 @@ async def bulk_update_order(
) -> None:
for page_id, order in pages:
await db.execute(
update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.utcnow())
update(FaqPage)
.where(FaqPage.id == page_id)
.values(display_order=order, updated_at=datetime.utcnow())
)
await db.commit()
-140
View File
@@ -1,140 +0,0 @@
"""CRUD операции для платежей Freekassa."""
import json
import logging
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import FreekassaPayment
logger = logging.getLogger(__name__)
async def create_freekassa_payment(
db: AsyncSession,
*,
user_id: int,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
expires_at: datetime | None = None,
metadata_json: str | None = None,
) -> FreekassaPayment:
"""Создает запись о платеже Freekassa."""
payment = FreekassaPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
expires_at=expires_at,
metadata_json=json.loads(metadata_json) if metadata_json else None,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f'Создан платеж Freekassa: order_id={order_id}, user_id={user_id}')
return payment
async def get_freekassa_payment_by_order_id(db: AsyncSession, order_id: str) -> FreekassaPayment | None:
"""Получает платеж по order_id."""
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_freekassa_payment_by_fk_order_id(db: AsyncSession, freekassa_order_id: str) -> FreekassaPayment | None:
"""Получает платеж по ID от Freekassa (intid)."""
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.freekassa_order_id == freekassa_order_id))
return result.scalar_one_or_none()
async def get_freekassa_payment_by_id(db: AsyncSession, payment_id: int) -> FreekassaPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(FreekassaPayment).where(FreekassaPayment.id == payment_id))
return result.scalar_one_or_none()
async def update_freekassa_payment_status(
db: AsyncSession,
payment: FreekassaPayment,
*,
status: str,
is_paid: bool = False,
freekassa_order_id: str | None = None,
payment_system_id: int | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> FreekassaPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.utcnow()
if is_paid:
payment.paid_at = datetime.utcnow()
if freekassa_order_id:
payment.freekassa_order_id = freekassa_order_id
if payment_system_id is not None:
payment.payment_system_id = payment_system_id
if callback_payload:
payment.callback_payload = callback_payload
if transaction_id:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(f'Обновлен статус платежа Freekassa: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
return payment
async def get_pending_freekassa_payments(db: AsyncSession, user_id: int) -> list[FreekassaPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.user_id == user_id,
FreekassaPayment.status == 'pending',
FreekassaPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_user_freekassa_payments(
db: AsyncSession,
user_id: int,
limit: int = 10,
offset: int = 0,
) -> list[FreekassaPayment]:
"""Получает платежи пользователя с пагинацией."""
result = await db.execute(
select(FreekassaPayment)
.where(FreekassaPayment.user_id == user_id)
.order_by(FreekassaPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def get_expired_pending_payments(
db: AsyncSession,
) -> list[FreekassaPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.status == 'pending',
FreekassaPayment.is_paid == False,
FreekassaPayment.expires_at < now,
)
)
return list(result.scalars().all())
+35 -30
View File
@@ -1,6 +1,6 @@
import logging
from datetime import datetime
from typing import Any
from typing import Any, Dict, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,7 +8,6 @@ from sqlalchemy.orm import selectinload
from app.database.models import HeleketPayment
logger = logging.getLogger(__name__)
@@ -21,13 +20,13 @@ async def create_heleket_payment(
amount: str,
currency: str,
status: str,
payer_amount: str | None = None,
payer_currency: str | None = None,
exchange_rate: float | None = None,
discount_percent: int | None = None,
payment_url: str | None = None,
expires_at: datetime | None = None,
metadata: dict[str, Any] | None = None,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
payment_url: Optional[str] = None,
expires_at: Optional[datetime] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> HeleketPayment:
payment = HeleketPayment(
user_id=user_id,
@@ -50,7 +49,7 @@ async def create_heleket_payment(
await db.refresh(payment)
logger.info(
'Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s',
"Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s",
uuid,
order_id,
amount,
@@ -64,9 +63,11 @@ async def create_heleket_payment(
async def get_heleket_payment_by_uuid(
db: AsyncSession,
uuid: str,
) -> HeleketPayment | None:
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.uuid == uuid)
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.uuid == uuid)
)
return result.scalar_one_or_none()
@@ -74,9 +75,11 @@ async def get_heleket_payment_by_uuid(
async def get_heleket_payment_by_order_id(
db: AsyncSession,
order_id: str,
) -> HeleketPayment | None:
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.order_id == order_id)
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.order_id == order_id)
)
return result.scalar_one_or_none()
@@ -84,9 +87,11 @@ async def get_heleket_payment_by_order_id(
async def get_heleket_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> HeleketPayment | None:
) -> Optional[HeleketPayment]:
result = await db.execute(
select(HeleketPayment).options(selectinload(HeleketPayment.user)).where(HeleketPayment.id == payment_id)
select(HeleketPayment)
.options(selectinload(HeleketPayment.user))
.where(HeleketPayment.id == payment_id)
)
return result.scalar_one_or_none()
@@ -95,19 +100,19 @@ async def update_heleket_payment(
db: AsyncSession,
uuid: str,
*,
status: str | None = None,
payer_amount: str | None = None,
payer_currency: str | None = None,
exchange_rate: float | None = None,
discount_percent: int | None = None,
paid_at: datetime | None = None,
payment_url: str | None = None,
metadata: dict[str, Any] | None = None,
) -> HeleketPayment | None:
status: Optional[str] = None,
payer_amount: Optional[str] = None,
payer_currency: Optional[str] = None,
exchange_rate: Optional[float] = None,
discount_percent: Optional[int] = None,
paid_at: Optional[datetime] = None,
payment_url: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[HeleketPayment]:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error('Heleket платеж с uuid=%s не найден', uuid)
logger.error("Heleket платеж с uuid=%s не найден", uuid)
return None
if status is not None:
@@ -135,7 +140,7 @@ async def update_heleket_payment(
await db.refresh(payment)
logger.info(
'Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s',
"Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s",
uuid,
payment.status,
payment.payer_amount,
@@ -149,11 +154,11 @@ async def link_heleket_payment_to_transaction(
db: AsyncSession,
uuid: str,
transaction_id: int,
) -> HeleketPayment | None:
) -> Optional[HeleketPayment]:
payment = await get_heleket_payment_by_uuid(db, uuid)
if not payment:
logger.error('Не найден Heleket платеж для связи с транзакцией: %s', uuid)
logger.error("Не найден Heleket платеж для связи с транзакцией: %s", uuid)
return None
payment.transaction_id = transaction_id
@@ -163,7 +168,7 @@ async def link_heleket_payment_to_transaction(
await db.refresh(payment)
logger.info(
'Heleket платеж %s связан с транзакцией %s',
"Heleket платеж %s связан с транзакцией %s",
uuid,
transaction_id,
)
-142
View File
@@ -1,142 +0,0 @@
"""CRUD операции для платежей KassaAI."""
import json
import logging
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import KassaAiPayment
logger = logging.getLogger(__name__)
async def create_kassa_ai_payment(
db: AsyncSession,
*,
user_id: int,
order_id: str,
amount_kopeks: int,
currency: str = 'RUB',
description: str | None = None,
payment_url: str | None = None,
payment_system_id: int | None = None,
expires_at: datetime | None = None,
metadata_json: str | None = None,
) -> KassaAiPayment:
"""Создает запись о платеже KassaAI."""
payment = KassaAiPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_system_id=payment_system_id,
expires_at=expires_at,
metadata_json=json.loads(metadata_json) if metadata_json else None,
status='pending',
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f'Создан платеж KassaAI: order_id={order_id}, user_id={user_id}')
return payment
async def get_kassa_ai_payment_by_order_id(db: AsyncSession, order_id: str) -> KassaAiPayment | None:
"""Получает платеж по order_id."""
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.order_id == order_id))
return result.scalar_one_or_none()
async def get_kassa_ai_payment_by_external_order_id(db: AsyncSession, kassa_ai_order_id: str) -> KassaAiPayment | None:
"""Получает платеж по ID от KassaAI (orderId)."""
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.kassa_ai_order_id == kassa_ai_order_id))
return result.scalar_one_or_none()
async def get_kassa_ai_payment_by_id(db: AsyncSession, payment_id: int) -> KassaAiPayment | None:
"""Получает платеж по ID."""
result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.id == payment_id))
return result.scalar_one_or_none()
async def update_kassa_ai_payment_status(
db: AsyncSession,
payment: KassaAiPayment,
*,
status: str,
is_paid: bool = False,
kassa_ai_order_id: str | None = None,
payment_system_id: int | None = None,
callback_payload: dict | None = None,
transaction_id: int | None = None,
) -> KassaAiPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.utcnow()
if is_paid:
payment.paid_at = datetime.utcnow()
if kassa_ai_order_id:
payment.kassa_ai_order_id = kassa_ai_order_id
if payment_system_id is not None:
payment.payment_system_id = payment_system_id
if callback_payload:
payment.callback_payload = callback_payload
if transaction_id:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(f'Обновлен статус платежа KassaAI: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
return payment
async def get_pending_kassa_ai_payments(db: AsyncSession, user_id: int) -> list[KassaAiPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.user_id == user_id,
KassaAiPayment.status == 'pending',
KassaAiPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_user_kassa_ai_payments(
db: AsyncSession,
user_id: int,
limit: int = 10,
offset: int = 0,
) -> list[KassaAiPayment]:
"""Получает платежи пользователя с пагинацией."""
result = await db.execute(
select(KassaAiPayment)
.where(KassaAiPayment.user_id == user_id)
.order_by(KassaAiPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def get_expired_pending_kassa_ai_payments(
db: AsyncSession,
) -> list[KassaAiPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.status == 'pending',
KassaAiPayment.is_paid == False,
KassaAiPayment.expires_at < now,
)
)
return list(result.scalars().all())
+19 -12
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Optional, Sequence
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,8 +20,8 @@ async def count_main_menu_buttons(db: AsyncSession) -> int:
async def get_main_menu_buttons(
db: AsyncSession,
*,
limit: int | None = None,
offset: int | None = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> list[MainMenuButton]:
stmt = select(MainMenuButton).order_by(
MainMenuButton.display_order.asc(),
@@ -37,8 +37,12 @@ async def get_main_menu_buttons(
return list(result.scalars().all())
async def get_main_menu_button_by_id(db: AsyncSession, button_id: int) -> MainMenuButton | None:
result = await db.execute(select(MainMenuButton).where(MainMenuButton.id == button_id))
async def get_main_menu_button_by_id(
db: AsyncSession, button_id: int
) -> MainMenuButton | None:
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id == button_id)
)
return result.scalar_one_or_none()
@@ -64,7 +68,7 @@ async def create_main_menu_button(
action_value: str,
visibility: MainMenuButtonVisibility | str = MainMenuButtonVisibility.ALL,
is_active: bool = True,
display_order: int | None = None,
display_order: Optional[int] = None,
) -> MainMenuButton:
if display_order is None:
display_order = await get_next_display_order(db)
@@ -73,7 +77,8 @@ async def create_main_menu_button(
text=text,
action_type=_enum_value(action_type, MainMenuButtonActionType),
action_value=action_value,
visibility=_enum_value(visibility, MainMenuButtonVisibility) or MainMenuButtonVisibility.ALL.value,
visibility=_enum_value(visibility, MainMenuButtonVisibility)
or MainMenuButtonVisibility.ALL.value,
is_active=bool(is_active),
display_order=int(display_order),
)
@@ -88,12 +93,12 @@ async def update_main_menu_button(
db: AsyncSession,
button: MainMenuButton,
*,
text: str | None = None,
text: Optional[str] = None,
action_type: MainMenuButtonActionType | str | None = None,
action_value: str | None = None,
action_value: Optional[str] = None,
visibility: MainMenuButtonVisibility | str | None = None,
is_active: bool | None = None,
display_order: int | None = None,
is_active: Optional[bool] = None,
display_order: Optional[int] = None,
) -> MainMenuButton:
if text is not None:
button.text = text
@@ -127,7 +132,9 @@ async def reorder_main_menu_buttons(
order_map = {int(button_id): index for index, button_id in enumerate(ordered_ids)}
result = await db.execute(select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys())))
result = await db.execute(
select(MainMenuButton).where(MainMenuButton.id.in_(order_map.keys()))
)
buttons = result.scalars().all()
for button in buttons:
+29 -31
View File
@@ -1,5 +1,6 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,7 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import MulenPayPayment
logger = logging.getLogger(__name__)
@@ -18,11 +18,11 @@ async def create_mulenpay_payment(
amount_kopeks: int,
uuid: str,
description: str,
payment_url: str | None,
mulen_payment_id: int | None,
payment_url: Optional[str],
mulen_payment_id: Optional[int],
currency: str,
status: str,
metadata: dict | None = None,
metadata: Optional[dict] = None,
) -> MulenPayPayment:
payment = MulenPayPayment(
user_id=user_id,
@@ -41,7 +41,7 @@ async def create_mulenpay_payment(
await db.refresh(payment)
logger.info(
'Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s',
"Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s",
settings.get_mulenpay_display_name(),
payment.mulen_payment_id,
uuid,
@@ -52,18 +52,32 @@ async def create_mulenpay_payment(
return payment
async def get_mulenpay_payment_by_local_id(db: AsyncSession, payment_id: int) -> MulenPayPayment | None:
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.id == payment_id))
async def get_mulenpay_payment_by_local_id(
db: AsyncSession, payment_id: int
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_mulenpay_payment_by_uuid(db: AsyncSession, uuid: str) -> MulenPayPayment | None:
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.uuid == uuid))
async def get_mulenpay_payment_by_uuid(
db: AsyncSession, uuid: str
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(MulenPayPayment.uuid == uuid)
)
return result.scalar_one_or_none()
async def get_mulenpay_payment_by_mulen_id(db: AsyncSession, mulen_payment_id: int) -> MulenPayPayment | None:
result = await db.execute(select(MulenPayPayment).where(MulenPayPayment.mulen_payment_id == mulen_payment_id))
async def get_mulenpay_payment_by_mulen_id(
db: AsyncSession, mulen_payment_id: int
) -> Optional[MulenPayPayment]:
result = await db.execute(
select(MulenPayPayment).where(
MulenPayPayment.mulen_payment_id == mulen_payment_id
)
)
return result.scalar_one_or_none()
@@ -72,11 +86,10 @@ async def update_mulenpay_payment_status(
*,
payment: MulenPayPayment,
status: str,
is_paid: bool | None = None,
paid_at: datetime | None = None,
callback_payload: dict | None = None,
mulen_payment_id: int | None = None,
metadata: dict | None = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
callback_payload: Optional[dict] = None,
mulen_payment_id: Optional[int] = None,
) -> MulenPayPayment:
payment.status = status
if is_paid is not None:
@@ -87,8 +100,6 @@ async def update_mulenpay_payment_status(
payment.callback_payload = callback_payload
if mulen_payment_id is not None and not payment.mulen_payment_id:
payment.mulen_payment_id = mulen_payment_id
if metadata is not None:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
await db.commit()
@@ -96,19 +107,6 @@ async def update_mulenpay_payment_status(
return payment
async def update_mulenpay_payment_metadata(
db: AsyncSession,
*,
payment: MulenPayPayment,
metadata: dict,
) -> MulenPayPayment:
payment.metadata_json = metadata
payment.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(payment)
return payment
async def link_mulenpay_payment_to_transaction(
db: AsyncSession,
*,
+11 -13
View File
@@ -1,11 +1,10 @@
import logging
from sqlalchemy import delete, select
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.database.models import SentNotification
logger = logging.getLogger(__name__)
@@ -14,19 +13,17 @@ async def notification_sent(
user_id: int,
subscription_id: int,
notification_type: str,
days_before: int | None = None,
days_before: Optional[int] = None,
) -> bool:
result = await db.execute(
select(SentNotification)
.where(
select(SentNotification).where(
SentNotification.user_id == user_id,
SentNotification.subscription_id == subscription_id,
SentNotification.notification_type == notification_type,
SentNotification.days_before == days_before,
)
.limit(1)
)
return result.scalars().first() is not None
return result.scalar_one_or_none() is not None
async def record_notification(
@@ -34,11 +31,8 @@ async def record_notification(
user_id: int,
subscription_id: int,
notification_type: str,
days_before: int | None = None,
days_before: Optional[int] = None,
) -> None:
already_exists = await notification_sent(db, user_id, subscription_id, notification_type, days_before)
if already_exists:
return
notification = SentNotification(
user_id=user_id,
subscription_id=subscription_id,
@@ -50,7 +44,11 @@ async def record_notification(
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
await db.execute(delete(SentNotification).where(SentNotification.subscription_id == subscription_id))
await db.execute(
delete(SentNotification).where(
SentNotification.subscription_id == subscription_id
)
)
await db.commit()
+56 -45
View File
@@ -4,14 +4,13 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from typing import Any, Dict, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Pal24Payment
logger = logging.getLogger(__name__)
@@ -21,15 +20,15 @@ async def create_pal24_payment(
user_id: int,
bill_id: str,
amount_kopeks: int,
description: str | None,
description: Optional[str],
status: str,
type_: str,
currency: str,
link_url: str | None,
link_page_url: str | None,
order_id: str | None = None,
ttl: int | None = None,
metadata: dict[str, Any] | None = None,
link_url: Optional[str],
link_page_url: Optional[str],
order_id: Optional[str] = None,
ttl: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Pal24Payment:
payment = Pal24Payment(
user_id=user_id,
@@ -51,7 +50,7 @@ async def create_pal24_payment(
await db.refresh(payment)
logger.info(
'Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)',
"Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)",
payment.id,
user_id,
amount_kopeks,
@@ -61,18 +60,24 @@ async def create_pal24_payment(
return payment
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Pal24Payment | None:
result = await db.execute(select(Pal24Payment).where(Pal24Payment.id == payment_id))
async def get_pal24_payment_by_id(db: AsyncSession, payment_id: int) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Pal24Payment | None:
result = await db.execute(select(Pal24Payment).where(Pal24Payment.bill_id == bill_id))
async def get_pal24_payment_by_bill_id(db: AsyncSession, bill_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.bill_id == bill_id)
)
return result.scalar_one_or_none()
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Pal24Payment | None:
result = await db.execute(select(Pal24Payment).where(Pal24Payment.order_id == order_id))
async def get_pal24_payment_by_order_id(db: AsyncSession, order_id: str) -> Optional[Pal24Payment]:
result = await db.execute(
select(Pal24Payment).where(Pal24Payment.order_id == order_id)
)
return result.scalar_one_or_none()
@@ -81,54 +86,55 @@ async def update_pal24_payment_status(
payment: Pal24Payment,
*,
status: str,
is_active: bool | None = None,
is_paid: bool | None = None,
paid_at: datetime | None = None,
payment_id: str | None = None,
payment_status: str | None = None,
payment_method: str | None = None,
balance_amount: str | None = None,
balance_currency: str | None = None,
payer_account: str | None = None,
callback_payload: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
is_active: Optional[bool] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
payment_id: Optional[str] = None,
payment_status: Optional[str] = None,
payment_method: Optional[str] = None,
balance_amount: Optional[str] = None,
balance_currency: Optional[str] = None,
payer_account: Optional[str] = None,
callback_payload: Optional[Dict[str, Any]] = None,
) -> Pal24Payment:
update_values: dict[str, Any] = {
'status': status,
update_values: Dict[str, Any] = {
"status": status,
}
if is_active is not None:
update_values['is_active'] = is_active
update_values["is_active"] = is_active
if is_paid is not None:
update_values['is_paid'] = is_paid
update_values["is_paid"] = is_paid
if paid_at is not None:
update_values['paid_at'] = paid_at
update_values["paid_at"] = paid_at
if payment_id is not None:
update_values['payment_id'] = payment_id
update_values["payment_id"] = payment_id
if payment_status is not None:
update_values['payment_status'] = payment_status
update_values["payment_status"] = payment_status
if payment_method is not None:
update_values['payment_method'] = payment_method
update_values["payment_method"] = payment_method
if balance_amount is not None:
update_values['balance_amount'] = balance_amount
update_values["balance_amount"] = balance_amount
if balance_currency is not None:
update_values['balance_currency'] = balance_currency
update_values["balance_currency"] = balance_currency
if payer_account is not None:
update_values['payer_account'] = payer_account
update_values["payer_account"] = payer_account
if callback_payload is not None:
update_values['callback_payload'] = callback_payload
if metadata is not None:
update_values['metadata_json'] = metadata
update_values["callback_payload"] = callback_payload
update_values['last_status'] = status
update_values["last_status"] = status
await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(**update_values))
await db.execute(
update(Pal24Payment)
.where(Pal24Payment.id == payment.id)
.values(**update_values)
)
await db.commit()
await db.refresh(payment)
logger.info(
'Обновлен Pal24 платеж %s: статус=%s, is_paid=%s',
"Обновлен Pal24 платеж %s: статус=%s, is_paid=%s",
payment.bill_id,
payment.status,
payment.is_paid,
@@ -142,12 +148,17 @@ async def link_pal24_payment_to_transaction(
payment: Pal24Payment,
transaction_id: int,
) -> Pal24Payment:
await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(transaction_id=transaction_id))
await db.execute(
update(Pal24Payment)
.where(Pal24Payment.id == payment.id)
.values(transaction_id=transaction_id)
)
await db.commit()
await db.refresh(payment)
logger.info(
'Pal24 платеж %s привязан к транзакции %s',
"Pal24 платеж %s привязан к транзакции %s",
payment.bill_id,
transaction_id,
)
return payment
+48 -27
View File
@@ -4,14 +4,13 @@ from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PlategaPayment
logger = logging.getLogger(__name__)
@@ -21,17 +20,17 @@ async def create_platega_payment(
user_id: int,
amount_kopeks: int,
currency: str,
description: str | None,
description: Optional[str],
status: str,
payment_method_code: int,
correlation_id: str,
platega_transaction_id: str | None,
redirect_url: str | None,
return_url: str | None,
failed_url: str | None,
payload: str | None,
metadata: dict[str, Any] | None = None,
expires_at: datetime | None = None,
platega_transaction_id: Optional[str],
redirect_url: Optional[str],
return_url: Optional[str],
failed_url: Optional[str],
payload: Optional[str],
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
payment = PlategaPayment(
user_id=user_id,
@@ -55,7 +54,7 @@ async def create_platega_payment(
await db.refresh(payment)
logger.info(
'Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s',
"Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s",
payment.id,
platega_transaction_id,
amount_kopeks,
@@ -65,23 +64,45 @@ async def create_platega_payment(
return payment
async def get_platega_payment_by_id(db: AsyncSession, payment_id: int) -> PlategaPayment | None:
result = await db.execute(select(PlategaPayment).where(PlategaPayment.id == payment_id))
async def get_platega_payment_by_id(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(PlategaPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_id_for_update(db: AsyncSession, payment_id: int) -> PlategaPayment | None:
result = await db.execute(select(PlategaPayment).where(PlategaPayment.id == payment_id).with_for_update())
async def get_platega_payment_by_id_for_update(
db: AsyncSession, payment_id: int
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment)
.where(PlategaPayment.id == payment_id)
.with_for_update()
)
return result.scalar_one_or_none()
async def get_platega_payment_by_transaction_id(db: AsyncSession, transaction_id: str) -> PlategaPayment | None:
result = await db.execute(select(PlategaPayment).where(PlategaPayment.platega_transaction_id == transaction_id))
async def get_platega_payment_by_transaction_id(
db: AsyncSession, transaction_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.platega_transaction_id == transaction_id
)
)
return result.scalar_one_or_none()
async def get_platega_payment_by_correlation_id(db: AsyncSession, correlation_id: str) -> PlategaPayment | None:
result = await db.execute(select(PlategaPayment).where(PlategaPayment.correlation_id == correlation_id))
async def get_platega_payment_by_correlation_id(
db: AsyncSession, correlation_id: str
) -> Optional[PlategaPayment]:
result = await db.execute(
select(PlategaPayment).where(
PlategaPayment.correlation_id == correlation_id
)
)
return result.scalar_one_or_none()
@@ -89,14 +110,14 @@ async def update_platega_payment(
db: AsyncSession,
*,
payment: PlategaPayment,
status: str | None = None,
is_paid: bool | None = None,
paid_at: datetime | None = None,
platega_transaction_id: str | None = None,
redirect_url: str | None = None,
callback_payload: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
expires_at: datetime | None = None,
status: Optional[str] = None,
is_paid: Optional[bool] = None,
paid_at: Optional[datetime] = None,
platega_transaction_id: Optional[str] = None,
redirect_url: Optional[str] = None,
callback_payload: Optional[dict[str, Any]] = None,
metadata: Optional[dict[str, Any]] = None,
expires_at: Optional[datetime] = None,
) -> PlategaPayment:
if status is not None:
payment.status = status
+30 -26
View File
@@ -1,5 +1,5 @@
import logging
from collections.abc import Iterable, Sequence
from typing import Iterable, Sequence
from sqlalchemy import and_, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,7 +13,6 @@ from app.database.models import (
PollResponse,
)
logger = logging.getLogger(__name__)
@@ -38,7 +37,7 @@ async def create_poll(
await db.flush()
for order, question_data in enumerate(questions, start=1):
question_text = question_data.get('text', '').strip()
question_text = question_data.get("text", "").strip()
if not question_text:
continue
@@ -50,7 +49,7 @@ async def create_poll(
db.add(question)
await db.flush()
for option_order, option_text in enumerate(question_data.get('options', []), start=1):
for option_order, option_text in enumerate(question_data.get("options", []), start=1):
option_text = option_text.strip()
if not option_text:
continue
@@ -64,7 +63,7 @@ async def create_poll(
await db.commit()
await db.refresh(
poll,
attribute_names=['questions'],
attribute_names=["questions"],
)
return poll
@@ -72,7 +71,9 @@ async def create_poll(
async def list_polls(db: AsyncSession) -> list[Poll]:
result = await db.execute(
select(Poll)
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options)))
.options(
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
)
.order_by(Poll.created_at.desc())
)
return result.scalars().all()
@@ -97,7 +98,7 @@ async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
await db.delete(poll)
await db.commit()
logger.info('🗑️ Удалён опрос %s', poll_id)
logger.info("🗑️ Удалён опрос %s", poll_id)
return True
@@ -107,7 +108,8 @@ async def create_poll_response(
user_id: int,
) -> PollResponse:
result = await db.execute(
select(PollResponse).where(
select(PollResponse)
.where(
and_(
PollResponse.poll_id == poll_id,
PollResponse.user_id == user_id,
@@ -135,9 +137,8 @@ async def get_poll_response_by_id(
result = await db.execute(
select(PollResponse)
.options(
selectinload(PollResponse.poll).options(
selectinload(Poll.questions).options(selectinload(PollQuestion.options))
),
selectinload(PollResponse.poll)
.options(selectinload(Poll.questions).options(selectinload(PollQuestion.options))),
selectinload(PollResponse.answers),
selectinload(PollResponse.user),
)
@@ -154,7 +155,8 @@ async def record_poll_answer(
option_id: int,
) -> PollAnswer:
result = await db.execute(
select(PollAnswer).where(
select(PollAnswer)
.where(
and_(
PollAnswer.response_id == response_id,
PollAnswer.question_id == question_id,
@@ -180,7 +182,9 @@ async def record_poll_answer(
async def reset_poll_answers(db: AsyncSession, response_id: int) -> None:
await db.execute(delete(PollAnswer).where(PollAnswer.response_id == response_id))
await db.execute(
delete(PollAnswer).where(PollAnswer.response_id == response_id)
)
await db.commit()
@@ -237,27 +241,27 @@ async def get_poll_statistics(db: AsyncSession, poll_id: int) -> dict:
question_entry = questions_map.setdefault(
question_id,
{
'id': question_id,
'text': question_text,
'order': question_order,
'options': [],
"id": question_id,
"text": question_text,
"order": question_order,
"options": [],
},
)
question_entry['options'].append(
question_entry["options"].append(
{
'id': option_id,
'text': option_text,
'count': answer_count,
"id": option_id,
"text": option_text,
"count": answer_count,
}
)
questions = sorted(questions_map.values(), key=lambda item: item['order'])
questions = sorted(questions_map.values(), key=lambda item: item["order"])
return {
'total_responses': total_responses,
'completed_responses': completed_responses,
'reward_sum_kopeks': reward_sum,
'questions': questions,
"total_responses": total_responses,
"completed_responses": completed_responses,
"reward_sum_kopeks": reward_sum,
"questions": questions,
}
+12 -10
View File
@@ -1,17 +1,19 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PrivacyPolicy
logger = logging.getLogger(__name__)
async def get_privacy_policy(db: AsyncSession, language: str) -> PrivacyPolicy | None:
result = await db.execute(select(PrivacyPolicy).where(PrivacyPolicy.language == language))
async def get_privacy_policy(db: AsyncSession, language: str) -> Optional[PrivacyPolicy]:
result = await db.execute(
select(PrivacyPolicy).where(PrivacyPolicy.language == language)
)
return result.scalar_one_or_none()
@@ -25,13 +27,13 @@ async def upsert_privacy_policy(
policy = await get_privacy_policy(db, language)
if policy:
policy.content = content or ''
policy.content = content or ""
policy.updated_at = datetime.utcnow()
else:
policy = PrivacyPolicy(
language=language,
content=content or '',
is_enabled=bool(enable_if_new),
content=content or "",
is_enabled=True if enable_if_new else False,
)
db.add(policy)
@@ -39,7 +41,7 @@ async def upsert_privacy_policy(
await db.refresh(policy)
logger.info(
'✅ Политика конфиденциальности для языка %s обновлена (ID: %s)',
"✅ Политика конфиденциальности для языка %s обновлена (ID: %s)",
language,
policy.id,
)
@@ -60,7 +62,7 @@ async def set_privacy_policy_enabled(
else:
policy = PrivacyPolicy(
language=language,
content='',
content="",
is_enabled=bool(enabled),
)
db.add(policy)
@@ -69,9 +71,9 @@ async def set_privacy_policy_enabled(
await db.refresh(policy)
logger.info(
'✅ Статус политики конфиденциальности для языка %s обновлен: %s',
"✅ Статус политики конфиденциальности для языка %s обновлен: %s",
language,
'enabled' if policy.is_enabled else 'disabled',
"enabled" if policy.is_enabled else "disabled",
)
return policy
+73 -44
View File
@@ -1,4 +1,5 @@
import logging
from typing import Dict, List, Optional, Set, Tuple
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,11 +8,11 @@ from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, User, UserPromoGroup
def _normalize_period_discounts(period_discounts: dict[int, int] | None) -> dict[int, int]:
def _normalize_period_discounts(period_discounts: Optional[Dict[int, int]]) -> Dict[int, int]:
if not period_discounts:
return {}
normalized: dict[int, int] = {}
normalized: Dict[int, int] = {}
for key, value in period_discounts.items():
try:
@@ -24,7 +25,6 @@ def _normalize_period_discounts(period_discounts: dict[int, int] | None) -> dict
return normalized
logger = logging.getLogger(__name__)
@@ -32,8 +32,8 @@ async def get_promo_groups_with_counts(
db: AsyncSession,
*,
offset: int = 0,
limit: int | None = None,
) -> list[tuple[PromoGroup, int]]:
limit: Optional[int] = None,
) -> List[Tuple[PromoGroup, int]]:
query = (
select(PromoGroup, func.count(User.id))
.outerjoin(User, User.promo_group_id == PromoGroup.id)
@@ -50,7 +50,7 @@ async def get_promo_groups_with_counts(
return result.all()
async def get_auto_assign_promo_groups(db: AsyncSession) -> list[PromoGroup]:
async def get_auto_assign_promo_groups(db: AsyncSession) -> List[PromoGroup]:
result = await db.execute(
select(PromoGroup)
.where(PromoGroup.auto_assign_total_spent_kopeks.is_not(None))
@@ -69,7 +69,7 @@ async def has_auto_assign_promo_groups(db: AsyncSession) -> bool:
return bool(result.scalar_one())
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> PromoGroup | None:
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
return await db.get(PromoGroup, group_id)
@@ -78,8 +78,10 @@ async def count_promo_groups(db: AsyncSession) -> int:
return int(result.scalar_one())
async def get_default_promo_group(db: AsyncSession) -> PromoGroup | None:
result = await db.execute(select(PromoGroup).where(PromoGroup.is_default.is_(True)))
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
result = await db.execute(
select(PromoGroup).where(PromoGroup.is_default.is_(True))
)
return result.scalars().first()
@@ -91,15 +93,17 @@ async def create_promo_group(
server_discount_percent: int,
traffic_discount_percent: int,
device_discount_percent: int,
period_discounts: dict[int, int] | None = None,
auto_assign_total_spent_kopeks: int | None = None,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: bool = True,
is_default: bool = False,
) -> PromoGroup:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
auto_assign_total_spent_kopeks = (
max(0, auto_assign_total_spent_kopeks) if auto_assign_total_spent_kopeks is not None else None
max(0, auto_assign_total_spent_kopeks)
if auto_assign_total_spent_kopeks is not None
else None
)
existing_default = await get_default_promo_group(db)
@@ -121,7 +125,11 @@ async def create_promo_group(
await db.flush()
if should_be_default and existing_default and existing_default.id != promo_group.id:
await db.execute(update(PromoGroup).where(PromoGroup.id != promo_group.id).values(is_default=False))
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != promo_group.id)
.values(is_default=False)
)
await db.commit()
await db.refresh(promo_group)
@@ -135,7 +143,7 @@ async def create_promo_group(
promo_group.device_discount_percent,
normalized_period_discounts,
(auto_assign_total_spent_kopeks or 0) / 100,
'on' if promo_group.apply_discounts_to_addons else 'off',
"on" if promo_group.apply_discounts_to_addons else "off",
)
return promo_group
@@ -145,15 +153,15 @@ async def update_promo_group(
db: AsyncSession,
group: PromoGroup,
*,
name: str | None = None,
priority: int | None = None,
server_discount_percent: int | None = None,
traffic_discount_percent: int | None = None,
device_discount_percent: int | None = None,
period_discounts: dict[int, int] | None = None,
auto_assign_total_spent_kopeks: int | None = None,
apply_discounts_to_addons: bool | None = None,
is_default: bool | None = None,
name: Optional[str] = None,
priority: Optional[int] = None,
server_discount_percent: Optional[int] = None,
traffic_discount_percent: Optional[int] = None,
device_discount_percent: Optional[int] = None,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: Optional[bool] = None,
is_default: Optional[bool] = None,
) -> PromoGroup:
if name is not None:
group.name = name.strip()
@@ -177,19 +185,31 @@ async def update_promo_group(
if is_default:
group.is_default = True
await db.flush()
await db.execute(update(PromoGroup).where(PromoGroup.id != group.id).values(is_default=False))
elif group.is_default:
group.is_default = False
await db.flush()
replacement = await db.execute(
select(PromoGroup).where(PromoGroup.id != group.id).order_by(PromoGroup.id).limit(1)
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != group.id)
.values(is_default=False)
)
new_default = replacement.scalars().first()
if new_default:
await db.execute(update(PromoGroup).where(PromoGroup.id == new_default.id).values(is_default=True))
else:
# Не допускаем состояния без базовой промогруппы
group.is_default = True
else:
if group.is_default:
group.is_default = False
await db.flush()
replacement = await db.execute(
select(PromoGroup)
.where(PromoGroup.id != group.id)
.order_by(PromoGroup.id)
.limit(1)
)
new_default = replacement.scalars().first()
if new_default:
await db.execute(
update(PromoGroup)
.where(PromoGroup.id == new_default.id)
.values(is_default=True)
)
else:
# Не допускаем состояния без базовой промогруппы
group.is_default = True
await db.commit()
await db.refresh(group)
@@ -204,18 +224,21 @@ async def update_promo_group(
async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
if group.is_default:
logger.warning('Попытка удалить базовую промогруппу запрещена')
logger.warning("Попытка удалить базовую промогруппу запрещена")
return False
default_group = await get_default_promo_group(db)
if not default_group:
logger.error('Не найдена базовая промогруппа для reassignment')
logger.error("Не найдена базовая промогруппа для reassignment")
return False
# Получаем список пользователей, связанных с удаляемой промогруппой
affected_user_ids: set[int] = set()
user_ids_result = await db.execute(select(User.id).where(User.promo_group_id == group.id))
# Получаем список пользователей, связанных с удаляемой промогруппой
affected_user_ids: Set[int] = set()
user_ids_result = await db.execute(
select(User.id).where(User.promo_group_id == group.id)
)
affected_user_ids.update(user_ids_result.scalars().all())
promo_group_links_result = await db.execute(
@@ -223,7 +246,11 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
)
affected_user_ids.update(promo_group_links_result.scalars().all())
await db.execute(update(User).where(User.promo_group_id == group.id).values(promo_group_id=default_group.id))
await db.execute(
update(User)
.where(User.promo_group_id == group.id)
.values(promo_group_id=default_group.id)
)
if affected_user_ids:
existing_defaults_result = await db.execute(
@@ -238,7 +265,7 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
UserPromoGroup(
user_id=user_id,
promo_group_id=default_group.id,
assigned_by='system',
assigned_by="system",
)
)
@@ -260,7 +287,7 @@ async def get_promo_group_members(
*,
offset: int = 0,
limit: int = 20,
) -> list[User]:
) -> List[User]:
result = await db.execute(
select(User)
.options(selectinload(User.subscription))
@@ -273,5 +300,7 @@ async def get_promo_group_members(
async def count_promo_group_members(db: AsyncSession, group_id: int) -> int:
result = await db.execute(select(func.count(User.id)).where(User.promo_group_id == group_id))
result = await db.execute(
select(func.count(User.id)).where(User.promo_group_id == group_id)
)
return result.scalar_one()
+13 -13
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,20 +9,19 @@ from sqlalchemy.orm import selectinload
from app.database.models import PromoOfferLog
logger = logging.getLogger(__name__)
async def log_promo_offer_action(
db: AsyncSession,
*,
user_id: int | None,
offer_id: int | None,
user_id: Optional[int],
offer_id: Optional[int],
action: str,
source: str | None = None,
percent: int | None = None,
effect_type: str | None = None,
details: dict[str, object] | None = None,
source: Optional[str] = None,
percent: Optional[int] = None,
effect_type: Optional[str] = None,
details: Optional[Dict[str, object]] = None,
commit: bool = True,
) -> PromoOfferLog:
"""Persist a promo offer log entry."""
@@ -42,7 +42,7 @@ async def log_promo_offer_action(
await db.commit()
await db.refresh(entry)
except Exception:
logger.exception('Failed to commit promo offer log entry')
logger.exception("Failed to commit promo offer log entry")
raise
return entry
@@ -53,11 +53,11 @@ async def list_promo_offer_logs(
offset: int = 0,
limit: int = 20,
*,
user_id: int | None = None,
offer_id: int | None = None,
action: str | None = None,
source: str | None = None,
) -> tuple[list[PromoOfferLog], int]:
user_id: Optional[int] = None,
offer_id: Optional[int] = None,
action: Optional[str] = None,
source: Optional[str] = None,
) -> Tuple[List[PromoOfferLog], int]:
stmt = (
select(PromoOfferLog)
.options(
+110 -97
View File
@@ -1,118 +1,121 @@
from __future__ import annotations
from collections.abc import Iterable
from datetime import datetime
from typing import Iterable, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PromoOfferTemplate
UPDATED_TEMPLATE_MESSAGES = {
'extend_discount': (
'💎 Экономия {discount_percent}% при продлении\n\n'
'Скидка суммируется с промогруппой и действует один раз.\n'
'Срок действия предложения — {valid_hours} ч.\n'
'После активации скидка действует {active_discount_hours} ч.'
"extend_discount": (
"💎 Экономия {discount_percent}% при продлении\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
'purchase_discount': (
'🎯 Вернитесь со скидкой {discount_percent}%\n\n'
'Скидка суммируется с промогруппой и действует один раз.\n'
'Предложение действует {valid_hours} ч.\n'
'После активации скидка действует {active_discount_hours} ч.'
"purchase_discount": (
"🎯 Вернитесь со скидкой {discount_percent}%\n\n"
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
LEGACY_TEMPLATE_MESSAGES = {
'extend_discount': (
'💎 <b>Экономия {discount_percent}% при продлении</b>\n\n'
'Активируйте предложение и получите дополнительную скидку на оплату продления. '
'Она суммируется с вашими промогрупповыми скидками и действует один раз.\n'
'Срок действия предложения — {valid_hours} ч.\n'
'После активации скидка действует {active_discount_hours} ч.'
"extend_discount": (
"💎 <b>Экономия {discount_percent}% при продлении</b>\n\n"
"Активируйте предложение и получите дополнительную скидку на оплату продления. "
"Она суммируется с вашими промогрупповыми скидками и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
'purchase_discount': (
'🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\n\n'
'После активации мы применим дополнительную скидку к вашей следующей оплате подписки. '
'Скидка суммируется с промогруппой и действует один раз.\n'
'Предложение действует {valid_hours} ч.\n'
'После активации скидка действует {active_discount_hours} ч.'
"purchase_discount": (
"🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\n\n"
"После активации мы применим дополнительную скидку к вашей следующей оплате подписки. "
"Скидка суммируется с промогруппой и действует один раз.\n"
"Предложение действует {valid_hours} ч.\n"
"После активации скидка действует {active_discount_hours} ч."
),
}
DEFAULT_TEMPLATES: tuple[dict, ...] = (
{
'offer_type': 'test_access',
'name': 'Тестовые сервера',
'message_text': (
'🔥 <b>Испытайте новые сервера</b>\n\n'
'Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n'
'Предложение действительно {valid_hours} ч.'
"offer_type": "test_access",
"name": "Тестовые сервера",
"message_text": (
"🔥 <b>Испытайте новые сервера</b>\n\n"
"Активируйте предложение и получите временный доступ к дополнительным сквадам на {test_duration_hours} ч.\n"
"Предложение действительно {valid_hours} ч."
),
'button_text': '🚀 Попробовать серверы',
'valid_hours': 24,
'discount_percent': 0,
'bonus_amount_kopeks': 0,
'active_discount_hours': None,
'test_duration_hours': 24,
'test_squad_uuids': [],
"button_text": "🚀 Попробовать серверы",
"valid_hours": 24,
"discount_percent": 0,
"bonus_amount_kopeks": 0,
"active_discount_hours": None,
"test_duration_hours": 24,
"test_squad_uuids": [],
},
{
'offer_type': 'extend_discount',
'name': 'Скидка на продление',
'message_text': UPDATED_TEMPLATE_MESSAGES['extend_discount'],
'button_text': '🎁 Получить скидку',
'valid_hours': 24,
'discount_percent': 20,
'bonus_amount_kopeks': 0,
'active_discount_hours': 24,
'test_duration_hours': None,
'test_squad_uuids': [],
"offer_type": "extend_discount",
"name": "Скидка на продление",
"message_text": UPDATED_TEMPLATE_MESSAGES["extend_discount"],
"button_text": "🎁 Получить скидку",
"valid_hours": 24,
"discount_percent": 20,
"bonus_amount_kopeks": 0,
"active_discount_hours": 24,
"test_duration_hours": None,
"test_squad_uuids": [],
},
{
'offer_type': 'purchase_discount',
'name': 'Скидка на покупку',
'message_text': UPDATED_TEMPLATE_MESSAGES['purchase_discount'],
'button_text': '🎁 Забрать скидку',
'valid_hours': 48,
'discount_percent': 25,
'bonus_amount_kopeks': 0,
'active_discount_hours': 48,
'test_duration_hours': None,
'test_squad_uuids': [],
"offer_type": "purchase_discount",
"name": "Скидка на покупку",
"message_text": UPDATED_TEMPLATE_MESSAGES["purchase_discount"],
"button_text": "🎁 Забрать скидку",
"valid_hours": 48,
"discount_percent": 25,
"bonus_amount_kopeks": 0,
"active_discount_hours": 48,
"test_duration_hours": None,
"test_squad_uuids": [],
},
)
def _format_template_fields(payload: dict) -> dict:
data = dict(payload)
data.setdefault('valid_hours', 24)
data.setdefault('discount_percent', 0)
data.setdefault('bonus_amount_kopeks', 0)
data.setdefault('active_discount_hours', None)
data.setdefault('test_duration_hours', None)
data.setdefault('test_squad_uuids', [])
data.setdefault("valid_hours", 24)
data.setdefault("discount_percent", 0)
data.setdefault("bonus_amount_kopeks", 0)
data.setdefault("active_discount_hours", None)
data.setdefault("test_duration_hours", None)
data.setdefault("test_squad_uuids", [])
return data
async def ensure_default_templates(db: AsyncSession, *, created_by: int | None = None) -> list[PromoOfferTemplate]:
templates: list[PromoOfferTemplate] = []
async def ensure_default_templates(db: AsyncSession, *, created_by: Optional[int] = None) -> List[PromoOfferTemplate]:
templates: List[PromoOfferTemplate] = []
for template_data in DEFAULT_TEMPLATES:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data['offer_type'])
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == template_data["offer_type"])
)
existing = result.scalars().first()
if existing:
new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data['offer_type'])
legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data['offer_type'])
new_message = UPDATED_TEMPLATE_MESSAGES.get(template_data["offer_type"])
legacy_message = LEGACY_TEMPLATE_MESSAGES.get(template_data["offer_type"])
should_update = False
if (new_message and legacy_message and existing.message_text == legacy_message) or (
new_message and ('{bonus_amount' in existing.message_text or 'Мы начислим' in existing.message_text)
if new_message and legacy_message and existing.message_text == legacy_message:
should_update = True
elif new_message and (
"{bonus_amount" in existing.message_text or "Мы начислим" in existing.message_text
):
should_update = True
@@ -121,8 +124,12 @@ async def ensure_default_templates(db: AsyncSession, *, created_by: int | None =
existing.updated_at = datetime.utcnow()
await db.flush()
target_active_hours = template_data.get('active_discount_hours')
if target_active_hours is not None and target_active_hours > 0 and not existing.active_discount_hours:
target_active_hours = template_data.get("active_discount_hours")
if (
target_active_hours is not None
and target_active_hours > 0
and not existing.active_discount_hours
):
existing.active_discount_hours = target_active_hours
existing.updated_at = datetime.utcnow()
await db.flush()
@@ -131,16 +138,16 @@ async def ensure_default_templates(db: AsyncSession, *, created_by: int | None =
payload = _format_template_fields(template_data)
template = PromoOfferTemplate(
name=payload['name'],
offer_type=payload['offer_type'],
message_text=payload['message_text'],
button_text=payload['button_text'],
valid_hours=payload['valid_hours'],
discount_percent=payload['discount_percent'],
bonus_amount_kopeks=payload['bonus_amount_kopeks'],
active_discount_hours=payload['active_discount_hours'],
test_duration_hours=payload['test_duration_hours'],
test_squad_uuids=payload['test_squad_uuids'],
name=payload["name"],
offer_type=payload["offer_type"],
message_text=payload["message_text"],
button_text=payload["button_text"],
valid_hours=payload["valid_hours"],
discount_percent=payload["discount_percent"],
bonus_amount_kopeks=payload["bonus_amount_kopeks"],
active_discount_hours=payload["active_discount_hours"],
test_duration_hours=payload["test_duration_hours"],
test_squad_uuids=payload["test_squad_uuids"],
is_active=True,
created_by=created_by,
)
@@ -153,18 +160,24 @@ async def ensure_default_templates(db: AsyncSession, *, created_by: int | None =
return templates
async def list_promo_offer_templates(db: AsyncSession) -> list[PromoOfferTemplate]:
result = await db.execute(select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id))
async def list_promo_offer_templates(db: AsyncSession) -> List[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).order_by(PromoOfferTemplate.offer_type, PromoOfferTemplate.id)
)
return result.scalars().all()
async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> PromoOfferTemplate | None:
result = await db.execute(select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id))
async def get_promo_offer_template_by_id(db: AsyncSession, template_id: int) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.id == template_id)
)
return result.scalar_one_or_none()
async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> PromoOfferTemplate | None:
result = await db.execute(select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type))
async def get_promo_offer_template_by_type(db: AsyncSession, offer_type: str) -> Optional[PromoOfferTemplate]:
result = await db.execute(
select(PromoOfferTemplate).where(PromoOfferTemplate.offer_type == offer_type)
)
return result.scalar_one_or_none()
@@ -172,16 +185,16 @@ async def update_promo_offer_template(
db: AsyncSession,
template: PromoOfferTemplate,
*,
name: str | None = None,
message_text: str | None = None,
button_text: str | None = None,
valid_hours: int | None = None,
discount_percent: int | None = None,
bonus_amount_kopeks: int | None = None,
active_discount_hours: int | None = None,
test_duration_hours: int | None = None,
test_squad_uuids: Iterable[str] | None = None,
is_active: bool | None = None,
name: Optional[str] = None,
message_text: Optional[str] = None,
button_text: Optional[str] = None,
valid_hours: Optional[int] = None,
discount_percent: Optional[int] = None,
bonus_amount_kopeks: Optional[int] = None,
active_discount_hours: Optional[int] = None,
test_duration_hours: Optional[int] = None,
test_squad_uuids: Optional[Iterable[str]] = None,
is_active: Optional[bool] = None,
) -> PromoOfferTemplate:
if name is not None:
template.name = name

Some files were not shown because too many files have changed in this diff Show More