Compare commits
187 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b276d764a3 | |||
| ee78ab0932 | |||
| e9daa76d7e | |||
| 780c3956fa | |||
| e582802e39 | |||
| d13b20d380 | |||
| 5918f296ff | |||
| 2f1ef8a60d | |||
| cefb6602f7 | |||
| b6503f9af9 | |||
| 76f465e0f6 | |||
| 63ec894615 | |||
| c9c25613af | |||
| 6ecaa406aa | |||
| 22c8f73eac | |||
| d4bc7d0b51 | |||
| aa3c9231b0 | |||
| bce05d4bc4 | |||
| f107109091 | |||
| 826a554d55 | |||
| fd5801839c | |||
| abce475ccc | |||
| 22129ecbec | |||
| 2a2a3daaae | |||
| ac94d5d708 | |||
| d10ccc4844 | |||
| de733a5f08 | |||
| 4cab3f5ed4 | |||
| 26b85b16b5 | |||
| ea770c32e8 | |||
| 23f8bdfbec | |||
| a362ef9f25 | |||
| 1b736b381d | |||
| d343a317ee | |||
| 3fc9e63653 | |||
| 9f12462871 | |||
| e293470c14 | |||
| 6322e970ef | |||
| 8a40d54c03 | |||
| 0264c24743 | |||
| 1aade85fc9 | |||
| a5874a9873 | |||
| 6f9dc45e16 | |||
| 34db5a0d28 | |||
| 34517427b0 | |||
| cad9abd8bd | |||
| bf9728352e | |||
| 58c924b70a | |||
| 64ffffdf90 | |||
| e71a3c1af7 | |||
| 265d2b907b | |||
| 54ffe3e126 | |||
| 7e3d7d4771 | |||
| 05bfd89a02 | |||
| 3bf540427e | |||
| bf6dc3991a | |||
| fb9405cccd | |||
| 0b34e90372 | |||
| 04e83814b4 | |||
| 0df3018703 | |||
| 8eb2be15ff | |||
| 648fba6e2a | |||
| 3df8d0cb24 | |||
| a59a15af7f | |||
| c919953219 | |||
| 5e6baac07e | |||
| 42b06ea8bc | |||
| 07ded3710c | |||
| 7d0977066b | |||
| d8e57ab766 | |||
| 83cb1ec823 | |||
| da46e39c61 | |||
| 86dd18fbe7 | |||
| 21f34a9b08 | |||
| 0538d0e337 | |||
| 1e64b65586 | |||
| 90c3df6331 | |||
| b9ea5a80ac | |||
| 40d6514dee | |||
| 4a2b254d99 | |||
| 6c740ad984 | |||
| 929a8d5fa9 | |||
| b0bf5131d4 | |||
| ef8d9fe1ff | |||
| d57e0743a1 | |||
| 664cbff1ce | |||
| 6ea52bf406 | |||
| 0ddf24125b | |||
| f5a533e407 | |||
| dd87b4d408 | |||
| 2c6c7056e8 | |||
| 8a10b96fef | |||
| c0a85bad84 | |||
| fbfc00586c | |||
| 5723c3b379 | |||
| f1be66d1a5 | |||
| b3cdd3c03a | |||
| 01afce002a | |||
| 63a9c1afd1 | |||
| 0c9f667cd8 | |||
| dc428baa6a | |||
| f65aa6a82d | |||
| c986b127a7 | |||
| 94a7a5fce8 | |||
| f07bbe0fd0 | |||
| 46f61dbc57 | |||
| 41502cadcd | |||
| aa5e6841f8 | |||
| 8a98a7ee84 | |||
| e1d6c73557 | |||
| b4d66bd34d | |||
| 1496ec901d | |||
| 78ce6bd428 | |||
| 9ec52dcf95 | |||
| 80a13e6b71 | |||
| 9231f770a3 | |||
| f8ba587e5c | |||
| 9280eecf4c | |||
| a36b15e8ad | |||
| 3e833f8a8c | |||
| 48044b56c1 | |||
| 3dab9df26c | |||
| 65da3a57a7 | |||
| 791482a8a1 | |||
| 7b03f3e553 | |||
| 47698882fa | |||
| 2c4a55f7c7 | |||
| f8053e14c3 | |||
| 2073d42fa8 | |||
| 076eb89760 | |||
| 34878a2ef3 | |||
| 6ae573ed48 | |||
| 482c42e9bf | |||
| 66092af999 | |||
| c7eabdf90e | |||
| ee6f4b35b0 | |||
| 7aea87a227 | |||
| a2b039d092 | |||
| f2d2c0a8a4 | |||
| ea3033a088 | |||
| 07f445b485 | |||
| a94cb355aa | |||
| 9f14ae67da | |||
| 44247dee03 | |||
| c66af415d5 | |||
| 6b2d7618a7 | |||
| 866fa56f5b | |||
| ea48036010 | |||
| 4077b2a032 | |||
| a69500ce91 | |||
| 9a1e57a764 | |||
| 88657dd3e2 | |||
| 85d4c9c208 | |||
| 2bda60cd18 | |||
| 73e5e1b5a3 | |||
| 3f1b65b602 | |||
| fa7dd7434b | |||
| 0243d3a2ba | |||
| 0951c9f6dd | |||
| 6891cc1f36 | |||
| dbe805662a | |||
| cd5c293b71 | |||
| 58e789b094 | |||
| 3fd48807d1 | |||
| 76b32ea4fe | |||
| 60670e44a2 | |||
| 69f1b91ce7 | |||
| c78313b5db | |||
| 55a7ec6b11 | |||
| 41d94cd8e7 | |||
| 2422f56137 | |||
| bb7b95c971 | |||
| a17344858b | |||
| a45dd70531 | |||
| 2bcda34f1c | |||
| ac698b3a74 | |||
| e52a47bfb3 | |||
| 47d7bdd632 | |||
| f7be2911cd | |||
| 104cf899ee | |||
| 6f0e3c0bfd | |||
| 09d8261cbf | |||
| 3c9580dc30 | |||
| 1d72419243 | |||
| ae420a592a | |||
| a2586d88e1 | |||
| 0b61878de8 |
+77
-2
@@ -14,6 +14,7 @@ ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID=133 # Опционально: ID топика для уведомлений о чеках NaloGO
|
||||
# Автоматические отчеты
|
||||
ADMIN_REPORTS_ENABLED=false
|
||||
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
|
||||
@@ -38,6 +39,7 @@ CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
|
||||
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
|
||||
|
||||
# ===== DATABASE CONFIGURATION =====
|
||||
# Режим базы данных: "auto", "postgresql", "sqlite"
|
||||
@@ -64,8 +66,9 @@ REDIS_URL=redis://redis:6379/0
|
||||
REMNAWAVE_API_URL=https://panel.example.com
|
||||
REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth"
|
||||
# Тип авторизации: "api_key", "basic_auth", "caddy"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
REMNAWAVE_CADDY_TOKEN=YWRtaW46cGFzc3dvcmQ=
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
@@ -100,6 +103,9 @@ REMNAWAVE_USER_DELETE_MODE=delete
|
||||
TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10
|
||||
TRIAL_DEVICE_LIMIT=1
|
||||
# Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным
|
||||
# Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты.
|
||||
# TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости)
|
||||
TRIAL_PAYMENT_ENABLED=false
|
||||
TRIAL_ACTIVATION_PRICE=0
|
||||
|
||||
@@ -131,6 +137,26 @@ TRAFFIC_SELECTION_MODE=selectable
|
||||
# 0 = безлимит
|
||||
FIXED_TRAFFIC_LIMIT_GB=100
|
||||
|
||||
# ===== ДОКУПКА ТРАФИКА =====
|
||||
# Включить/выключить функцию докупки трафика к существующей подписке
|
||||
TRAFFIC_TOPUP_ENABLED=true
|
||||
|
||||
# Пакеты для докупки трафика (формат: "гб:цена_в_копейках:enabled")
|
||||
# Пустая строка = использовать TRAFFIC_PACKAGES_CONFIG
|
||||
# Пример: "10:5000:true,25:10000:true,50:15000:true,100:25000:true"
|
||||
TRAFFIC_TOPUP_PACKAGES_CONFIG=
|
||||
|
||||
# ===== СБРОС ТРАФИКА =====
|
||||
# Режим расчета цены сброса трафика:
|
||||
# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение, может быть абьюзом!)
|
||||
# "traffic" - цена = стоимость текущего пакета трафика подписки
|
||||
# "traffic_with_purchased" - цена = стоимость базового + докупленного трафика (рекомендуется)
|
||||
TRAFFIC_RESET_PRICE_MODE=traffic_with_purchased
|
||||
|
||||
# Базовая цена сброса в копейках (0 = использовать PERIOD_PRICES[30])
|
||||
# Используется как минимальная цена или фиксированная в режиме "period"
|
||||
TRAFFIC_RESET_BASE_PRICE=0
|
||||
|
||||
# ===== ПЕРИОДЫ ПОДПИСКИ =====
|
||||
# Доступные периоды подписки (через запятую)
|
||||
# Возможные значения: 14,30,60,90,180,360
|
||||
@@ -173,6 +199,16 @@ PRICE_PER_DEVICE=10000
|
||||
DEVICES_SELECTION_ENABLED=true
|
||||
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT=0
|
||||
|
||||
# ===== МОДЕМ =====
|
||||
# Включить функционал подключения модема
|
||||
MODEM_ENABLED=false
|
||||
# Цена модема в копейках за месяц (добавляется к ежемесячному платежу)
|
||||
MODEM_PRICE_PER_MONTH=10000
|
||||
# Скидки на модем за длительный срок: "месяцев:процент,месяцев:процент"
|
||||
# Пример: 3 мес = 15%, 6 мес = 20%, 12 мес = 25%
|
||||
MODEM_PERIOD_DISCOUNTS=3:15,6:20,12:25
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW=false
|
||||
|
||||
@@ -281,11 +317,13 @@ PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
|
||||
# ===== НАЛОГОВАЯ СЛУЖБА (NaloGO) =====
|
||||
# Автоматическая отправка чеков в налоговую при пополнении баланса
|
||||
NALOGO_ENABLED=false
|
||||
NALOGO_RECEIPTS_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 # Максимум попыток отправки одного чека
|
||||
|
||||
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
|
||||
# Эти настройки позволяют изменить описания платежей,
|
||||
@@ -459,6 +497,12 @@ DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
|
||||
# true: 14.78₽ → 15₽, 14.12₽ → 14₽
|
||||
# false: показывать точные суммы с копейками
|
||||
PRICE_ROUNDING_ENABLED=true
|
||||
|
||||
# Часовой пояс
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
@@ -484,6 +528,8 @@ 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
|
||||
@@ -494,6 +540,28 @@ 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=
|
||||
@@ -510,6 +578,13 @@ BOT_RUN_MODE=polling # polling, webhook или both
|
||||
CONTESTS_ENABLED=false
|
||||
CONTESTS_BUTTON_VISIBLE=false
|
||||
|
||||
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# Автоматическая покупка из сохранённой корзины после пополнения баланса
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
|
||||
# Умная автоактивация: система сама решает — продлить или создать подписку
|
||||
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
# ACTIVATE_BUTTON_TEXT=активировать
|
||||
|
||||
@@ -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="v2.9.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.4-$(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="v2.9.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.9.2-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.4-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.9.2"
|
||||
ARG VERSION="v2.9.4"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -61,12 +61,12 @@
|
||||
|
||||
### 📚 Поддерживаемые методы авторизации
|
||||
|
||||
| Метод | Заголовок | Описание |
|
||||
|-------|-----------|----------|
|
||||
| API Key | X-Api-Key: your_api_key | Стандартный API ключ |
|
||||
| Bearer Token | Authorization: Bearer token | Классический Bearer token |
|
||||
| Basic Auth | X-Api-Key: Basic base64(user:pass) | Basic Authentication |
|
||||
| eGames Cookies | Cookies в формате key:value | Для панелей eGames |
|
||||
| Конфигурация | Authorization | X-Api-Key |
|
||||
|------------------|---------------------|---------------------|
|
||||
| Только API Key | Bearer <api_key> | <api_key> |
|
||||
| Basic Auth | Bearer <api_key> | Basic <user:pass> |
|
||||
| Caddy + API Key | Basic <caddy_token> | <api_key> |
|
||||
| Cookies (eGames) | Bearer <api_key> | <api_key> + cookies |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+284
-11
@@ -45,6 +45,12 @@ class Settings(BaseSettings):
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: Optional[int] = None
|
||||
|
||||
# Настройки очереди чеков NaloGO
|
||||
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
|
||||
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
|
||||
NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека
|
||||
|
||||
ADMIN_REPORTS_ENABLED: bool = False
|
||||
ADMIN_REPORTS_CHAT_ID: Optional[str] = None
|
||||
@@ -55,7 +61,8 @@ class Settings(BaseSettings):
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
|
||||
|
||||
CHANNEL_REQUIRED_FOR_ALL: bool = False
|
||||
|
||||
DATABASE_URL: Optional[str] = None
|
||||
|
||||
POSTGRES_HOST: str = "postgres"
|
||||
@@ -79,7 +86,8 @@ class Settings(BaseSettings):
|
||||
|
||||
REMNAWAVE_USERNAME: Optional[str] = None
|
||||
REMNAWAVE_PASSWORD: Optional[str] = None
|
||||
REMNAWAVE_AUTH_TYPE: str = "api_key"
|
||||
REMNAWAVE_CADDY_TOKEN: Optional[str] = None
|
||||
REMNAWAVE_AUTH_TYPE: str = "api_key" # api_key, basic, bearer, cookies, caddy
|
||||
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
|
||||
REMNAWAVE_USER_USERNAME_TEMPLATE: str = "user_{telegram_id}"
|
||||
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
|
||||
@@ -142,11 +150,35 @@ class Settings(BaseSettings):
|
||||
DEVICES_SELECTION_ENABLED: bool = True
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
|
||||
|
||||
# Настройки модема
|
||||
MODEM_ENABLED: bool = False
|
||||
MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц
|
||||
MODEM_PERIOD_DISCOUNTS: str = "" # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20")
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
|
||||
|
||||
TRAFFIC_SELECTION_MODE: str = "selectable"
|
||||
FIXED_TRAFFIC_LIMIT_GB: int = 100
|
||||
# Режим выбора трафика:
|
||||
# - selectable: пользователь выбирает трафик при покупке и может докупать
|
||||
# - fixed: фиксированный лимит, без выбора и без докупки
|
||||
# - fixed_with_topup: фиксированный лимит при покупке, но докупка разрешена (при продлении сброс до лимита)
|
||||
TRAFFIC_SELECTION_MODE: str = "selectable"
|
||||
FIXED_TRAFFIC_LIMIT_GB: int = 100
|
||||
BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
|
||||
|
||||
# Настройки докупки трафика
|
||||
TRAFFIC_TOPUP_ENABLED: bool = True # Включить/выключить функцию докупки трафика
|
||||
# Пакеты для докупки трафика (формат: "гб:цена:enabled", пустая строка = использовать TRAFFIC_PACKAGES_CONFIG)
|
||||
TRAFFIC_TOPUP_PACKAGES_CONFIG: str = ""
|
||||
|
||||
# Настройки сброса трафика
|
||||
# Режимы расчета цены сброса:
|
||||
# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение)
|
||||
# "traffic" - цена зависит от текущего лимита трафика (цена пакета трафика)
|
||||
# "traffic_with_purchased" - цена = базовый трафик + докупленный трафик (рекомендуется)
|
||||
TRAFFIC_RESET_PRICE_MODE: str = "traffic_with_purchased"
|
||||
# Базовая цена сброса в копейках (используется если режим "period" или как минимальная цена)
|
||||
TRAFFIC_RESET_BASE_PRICE: int = 0 # 0 = использовать PERIOD_PRICES[30]
|
||||
|
||||
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
|
||||
@@ -235,13 +267,13 @@ class Settings(BaseSettings):
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
|
||||
|
||||
NALOGO_ENABLED: bool = False
|
||||
NALOGO_RECEIPTS_ENABLED: bool = False
|
||||
NALOGO_INN: Optional[str] = None
|
||||
NALOGO_PASSWORD: Optional[str] = None
|
||||
NALOGO_DEVICE_ID: Optional[str] = None
|
||||
NALOGO_STORAGE_PATH: str = "./nalogo_tokens.json"
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW: bool = False
|
||||
@@ -318,6 +350,7 @@ class Settings(BaseSettings):
|
||||
PLATEGA_ENABLED: bool = False
|
||||
PLATEGA_MERCHANT_ID: Optional[str] = None
|
||||
PLATEGA_SECRET: Optional[str] = None
|
||||
PLATEGA_DISPLAY_NAME: str = "Platega"
|
||||
PLATEGA_BASE_URL: str = "https://app.platega.io"
|
||||
PLATEGA_RETURN_URL: Optional[str] = None
|
||||
PLATEGA_FAILED_URL: Optional[str] = None
|
||||
@@ -347,6 +380,24 @@ class Settings(BaseSettings):
|
||||
WATA_PUBLIC_KEY_URL: Optional[str] = None
|
||||
WATA_PUBLIC_KEY_CACHE_SECONDS: int = 3600
|
||||
|
||||
# CloudPayments
|
||||
CLOUDPAYMENTS_ENABLED: bool = False
|
||||
CLOUDPAYMENTS_PUBLIC_ID: Optional[str] = None
|
||||
CLOUDPAYMENTS_API_SECRET: Optional[str] = None
|
||||
CLOUDPAYMENTS_API_URL: str = "https://api.cloudpayments.ru"
|
||||
CLOUDPAYMENTS_WIDGET_URL: str = "https://widget.cloudpayments.ru/show"
|
||||
CLOUDPAYMENTS_DESCRIPTION: str = "Пополнение баланса"
|
||||
CLOUDPAYMENTS_CURRENCY: str = "RUB"
|
||||
CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS: int = 5000
|
||||
CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
CLOUDPAYMENTS_WEBHOOK_PATH: str = "/cloudpayments-webhook"
|
||||
CLOUDPAYMENTS_WEBHOOK_HOST: str = "0.0.0.0"
|
||||
CLOUDPAYMENTS_WEBHOOK_PORT: int = 8087
|
||||
CLOUDPAYMENTS_RETURN_URL: Optional[str] = None
|
||||
CLOUDPAYMENTS_SKIN: str = "mini" # mini, classic, modern
|
||||
CLOUDPAYMENTS_REQUIRE_EMAIL: bool = False
|
||||
CLOUDPAYMENTS_TEST_MODE: bool = False
|
||||
|
||||
MAIN_MENU_MODE: str = "default"
|
||||
CONNECT_BUTTON_MODE: str = "guide"
|
||||
MINIAPP_CUSTOM_URL: str = ""
|
||||
@@ -372,10 +423,29 @@ class Settings(BaseSettings):
|
||||
DEFAULT_LANGUAGE: str = "ru"
|
||||
AVAILABLE_LANGUAGES: str = "ru,en"
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
|
||||
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
|
||||
PRICE_ROUNDING_ENABLED: bool = True
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FILE: str = "logs/bot.log"
|
||||
|
||||
|
||||
# === Log Rotation Settings ===
|
||||
LOG_ROTATION_ENABLED: bool = False # По умолчанию старое поведение
|
||||
LOG_ROTATION_TIME: str = "00:00" # Время ротации (HH:MM)
|
||||
LOG_ROTATION_KEEP_DAYS: int = 7 # Хранить архивы N дней
|
||||
LOG_ROTATION_COMPRESS: bool = True # Сжимать архивы gzip
|
||||
LOG_ROTATION_SEND_TO_TELEGRAM: bool = False # Отправлять в канал
|
||||
LOG_ROTATION_CHAT_ID: Optional[str] = None # Канал для логов (или BACKUP_SEND_CHAT_ID)
|
||||
LOG_ROTATION_TOPIC_ID: Optional[int] = None # Топик в канале
|
||||
|
||||
# Пути к лог-файлам (при LOG_ROTATION_ENABLED=true)
|
||||
LOG_DIR: str = "logs"
|
||||
LOG_INFO_FILE: str = "info.log"
|
||||
LOG_WARNING_FILE: str = "warning.log"
|
||||
LOG_ERROR_FILE: str = "error.log"
|
||||
LOG_PAYMENTS_FILE: str = "payments.log"
|
||||
|
||||
DEBUG: bool = False
|
||||
WEBHOOK_URL: Optional[str] = None
|
||||
WEBHOOK_PATH: str = "/webhook"
|
||||
@@ -418,6 +488,7 @@ class Settings(BaseSettings):
|
||||
BACKUP_SEND_ENABLED: bool = False
|
||||
BACKUP_SEND_CHAT_ID: Optional[str] = None
|
||||
BACKUP_SEND_TOPIC_ID: Optional[int] = None
|
||||
BACKUP_ARCHIVE_PASSWORD: Optional[str] = None
|
||||
|
||||
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
|
||||
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None
|
||||
@@ -575,6 +646,7 @@ class Settings(BaseSettings):
|
||||
"secret_key": self.REMNAWAVE_SECRET_KEY,
|
||||
"username": self.REMNAWAVE_USERNAME,
|
||||
"password": self.REMNAWAVE_PASSWORD,
|
||||
"caddy_token": self.REMNAWAVE_CADDY_TOKEN,
|
||||
"auth_type": self.REMNAWAVE_AUTH_TYPE
|
||||
}
|
||||
|
||||
@@ -739,7 +811,17 @@ class Settings(BaseSettings):
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
|
||||
def is_auto_activate_after_topup_enabled(self) -> bool:
|
||||
"""Умная автоактивация после пополнения баланса (без корзины)."""
|
||||
value = getattr(self, "AUTO_ACTIVATE_AFTER_TOPUP_ENABLED", False)
|
||||
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
return normalized in {"1", "true", "yes", "on"}
|
||||
|
||||
return bool(value)
|
||||
|
||||
def get_available_languages(self) -> List[str]:
|
||||
defaults = ["ru", "en", "ua", "zh"]
|
||||
|
||||
@@ -779,10 +861,32 @@ class Settings(BaseSettings):
|
||||
def is_language_selection_enabled(self) -> bool:
|
||||
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
|
||||
|
||||
def format_price(self, price_kopeks: int) -> str:
|
||||
sign = "-" if price_kopeks < 0 else ""
|
||||
rubles, kopeks = divmod(abs(price_kopeks), 100)
|
||||
def format_price(self, price_kopeks: int, round_kopeks: Optional[bool] = None) -> str:
|
||||
"""
|
||||
Форматирует цену в копейках для отображения пользователю.
|
||||
|
||||
Args:
|
||||
price_kopeks: Сумма в копейках
|
||||
round_kopeks: Если True, округляет копейки (≤50 вниз, >50 вверх).
|
||||
Если None, использует настройку PRICE_ROUNDING_ENABLED.
|
||||
|
||||
Returns:
|
||||
Отформатированная строка цены (например, "150 ₽")
|
||||
"""
|
||||
# Используем настройку если не передано явно
|
||||
should_round = round_kopeks if round_kopeks is not None else self.PRICE_ROUNDING_ENABLED
|
||||
|
||||
sign = "-" if price_kopeks < 0 else ""
|
||||
abs_kopeks = abs(price_kopeks)
|
||||
rubles, kopeks = divmod(abs_kopeks, 100)
|
||||
|
||||
if should_round:
|
||||
# Округление: ≤50 коп вниз, >50 коп вверх
|
||||
if kopeks > 50:
|
||||
rubles += 1
|
||||
return f"{sign}{rubles} ₽"
|
||||
|
||||
# Без округления - показываем точное значение
|
||||
if kopeks:
|
||||
value = f"{sign}{rubles}.{kopeks:02d}".rstrip("0").rstrip(".")
|
||||
return f"{value} ₽"
|
||||
@@ -958,11 +1062,67 @@ class Settings(BaseSettings):
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
|
||||
|
||||
def is_traffic_fixed(self) -> bool:
|
||||
"""Возвращает True если выбор трафика отключён (fixed или fixed_with_topup)"""
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() in ("fixed", "fixed_with_topup")
|
||||
|
||||
def is_traffic_topup_blocked(self) -> bool:
|
||||
"""Возвращает True если докупка трафика полностью заблокирована (только fixed)"""
|
||||
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
|
||||
|
||||
def get_fixed_traffic_limit(self) -> int:
|
||||
return self.FIXED_TRAFFIC_LIMIT_GB
|
||||
|
||||
def is_traffic_topup_enabled(self) -> bool:
|
||||
return self.TRAFFIC_TOPUP_ENABLED
|
||||
|
||||
def get_traffic_topup_packages(self) -> List[Dict]:
|
||||
"""Возвращает пакеты для докупки трафика. Если не настроены - использует TRAFFIC_PACKAGES_CONFIG."""
|
||||
config_str = self.TRAFFIC_TOPUP_PACKAGES_CONFIG.strip()
|
||||
|
||||
if not config_str:
|
||||
# Если не настроены отдельные пакеты для докупки - используем основные
|
||||
return self.get_traffic_packages()
|
||||
|
||||
packages = []
|
||||
for package_config in config_str.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
continue
|
||||
|
||||
parts = package_config.split(':')
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
gb = int(parts[0])
|
||||
price = int(parts[1])
|
||||
enabled = parts[2].lower() == 'true' if len(parts) > 2 else True
|
||||
packages.append({"gb": gb, "price": price, "enabled": enabled})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
return packages if packages else self.get_traffic_packages()
|
||||
|
||||
def get_traffic_topup_price(self, gb: Optional[int]) -> int:
|
||||
"""Возвращает цену докупки для указанного количества ГБ."""
|
||||
packages = self.get_traffic_topup_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
|
||||
if not enabled_packages:
|
||||
return 0
|
||||
|
||||
# Ищем точное совпадение
|
||||
for pkg in enabled_packages:
|
||||
if pkg["gb"] == gb:
|
||||
return pkg["price"]
|
||||
|
||||
# Если не нашли - возвращаем 0
|
||||
return 0
|
||||
|
||||
def get_traffic_reset_price_mode(self) -> str:
|
||||
return self.TRAFFIC_RESET_PRICE_MODE.lower()
|
||||
|
||||
def get_traffic_reset_base_price(self) -> int:
|
||||
return self.TRAFFIC_RESET_BASE_PRICE
|
||||
|
||||
def is_devices_selection_enabled(self) -> bool:
|
||||
return self.DEVICES_SELECTION_ENABLED
|
||||
|
||||
@@ -989,7 +1149,70 @@ class Settings(BaseSettings):
|
||||
def get_disabled_mode_device_limit(self) -> Optional[int]:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_modem_enabled(self) -> bool:
|
||||
return bool(self.MODEM_ENABLED)
|
||||
|
||||
def get_modem_price_per_month(self) -> int:
|
||||
try:
|
||||
value = int(self.MODEM_PRICE_PER_MONTH)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Некорректное значение MODEM_PRICE_PER_MONTH: %s",
|
||||
self.MODEM_PRICE_PER_MONTH,
|
||||
)
|
||||
return 10000
|
||||
return max(0, value)
|
||||
|
||||
def get_modem_period_discounts(self) -> Dict[int, int]:
|
||||
"""Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}"""
|
||||
try:
|
||||
config_str = (self.MODEM_PERIOD_DISCOUNTS or "").strip()
|
||||
if not config_str:
|
||||
return {}
|
||||
|
||||
discounts: Dict[int, int] = {}
|
||||
for part in config_str.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
months_and_discount = part.split(':')
|
||||
if len(months_and_discount) != 2:
|
||||
continue
|
||||
|
||||
months_str, discount_str = months_and_discount
|
||||
try:
|
||||
months = int(months_str.strip())
|
||||
discount_percent = int(discount_str.strip())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
discounts[months] = max(0, min(100, discount_percent))
|
||||
|
||||
return discounts
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_modem_period_discount(self, months: int) -> int:
|
||||
"""Возвращает процент скидки для указанного количества месяцев"""
|
||||
if months <= 0:
|
||||
return 0
|
||||
|
||||
discounts = self.get_modem_period_discounts()
|
||||
|
||||
# Ищем точное совпадение или ближайшее меньшее
|
||||
applicable_discount = 0
|
||||
for discount_months, discount_percent in sorted(discounts.items()):
|
||||
if months >= discount_months:
|
||||
applicable_discount = discount_percent
|
||||
|
||||
return applicable_discount
|
||||
|
||||
def is_trial_paid_activation_enabled(self) -> bool:
|
||||
# Если цена > 0, триал автоматически платный
|
||||
# (TRIAL_PAYMENT_ENABLED теперь опционален - для обратной совместимости)
|
||||
if self.TRIAL_ACTIVATION_PRICE > 0:
|
||||
return True
|
||||
return bool(self.TRIAL_PAYMENT_ENABLED)
|
||||
|
||||
def get_trial_activation_price(self) -> int:
|
||||
@@ -1083,6 +1306,15 @@ class Settings(BaseSettings):
|
||||
and self.PLATEGA_SECRET is not None
|
||||
)
|
||||
|
||||
def get_platega_display_name(self) -> str:
|
||||
name = (self.PLATEGA_DISPLAY_NAME or "").strip()
|
||||
if not name:
|
||||
return "Platega"
|
||||
return name
|
||||
|
||||
def get_platega_display_name_html(self) -> str:
|
||||
return html.escape(self.get_platega_display_name())
|
||||
|
||||
def get_platega_return_url(self) -> Optional[str]:
|
||||
if self.PLATEGA_RETURN_URL:
|
||||
return self.PLATEGA_RETURN_URL
|
||||
@@ -1151,6 +1383,13 @@ class Settings(BaseSettings):
|
||||
and self.WATA_TERMINAL_PUBLIC_ID is not None
|
||||
)
|
||||
|
||||
def is_cloudpayments_enabled(self) -> bool:
|
||||
return (
|
||||
self.CLOUDPAYMENTS_ENABLED
|
||||
and self.CLOUDPAYMENTS_PUBLIC_ID is not None
|
||||
and self.CLOUDPAYMENTS_API_SECRET is not None
|
||||
)
|
||||
|
||||
def is_payment_verification_auto_check_enabled(self) -> bool:
|
||||
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
|
||||
|
||||
@@ -1411,6 +1650,40 @@ class Settings(BaseSettings):
|
||||
return (self.BACKUP_SEND_ENABLED and
|
||||
self.get_backup_send_chat_id() is not None)
|
||||
|
||||
def get_backup_archive_password(self) -> Optional[str]:
|
||||
password = (self.BACKUP_ARCHIVE_PASSWORD or "").strip()
|
||||
return password if password else None
|
||||
|
||||
# === Log Rotation Methods ===
|
||||
|
||||
def is_log_rotation_enabled(self) -> bool:
|
||||
"""Проверить, включена ли новая система ротации логов."""
|
||||
return self.LOG_ROTATION_ENABLED
|
||||
|
||||
def get_log_rotation_chat_id(self) -> Optional[int]:
|
||||
"""Получить ID канала для отправки логов.
|
||||
|
||||
Если LOG_ROTATION_CHAT_ID не задан, использует BACKUP_SEND_CHAT_ID.
|
||||
"""
|
||||
chat_id = self.LOG_ROTATION_CHAT_ID or self.BACKUP_SEND_CHAT_ID
|
||||
if not chat_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
return int(chat_id)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def get_log_rotation_topic_id(self) -> Optional[int]:
|
||||
"""Получить ID топика для отправки логов.
|
||||
|
||||
Если LOG_ROTATION_TOPIC_ID не задан, использует BACKUP_SEND_TOPIC_ID.
|
||||
"""
|
||||
topic_id = self.LOG_ROTATION_TOPIC_ID
|
||||
if topic_id is not None:
|
||||
return topic_id
|
||||
return self.BACKUP_SEND_TOPIC_ID
|
||||
|
||||
def get_referral_settings(self) -> Dict:
|
||||
return {
|
||||
"program_enabled": self.is_referral_program_enabled(),
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""CRUD operations for CloudPayments payments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
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: Optional[str] = None,
|
||||
currency: str = "RUB",
|
||||
payment_url: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = 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,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
"""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,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
"""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,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
"""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,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
"""
|
||||
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: Optional[int] = None,
|
||||
token: Optional[str] = None,
|
||||
card_first_six: Optional[str] = None,
|
||||
card_last_four: Optional[str] = None,
|
||||
card_type: Optional[str] = None,
|
||||
card_exp_date: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
callback_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
"""
|
||||
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,
|
||||
) -> Optional[CloudPaymentsPayment]:
|
||||
"""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())
|
||||
@@ -40,7 +40,8 @@ async def upsert_template(
|
||||
slug: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
prize_days: int = 1,
|
||||
prize_type: str = "days",
|
||||
prize_value: str = "1",
|
||||
max_winners: int = 1,
|
||||
attempts_per_user: int = 1,
|
||||
times_per_day: int = 1,
|
||||
@@ -56,7 +57,8 @@ async def upsert_template(
|
||||
|
||||
template.name = name
|
||||
template.description = description
|
||||
template.prize_days = prize_days
|
||||
template.prize_type = prize_type
|
||||
template.prize_value = prize_value
|
||||
template.max_winners = max_winners
|
||||
template.attempts_per_user = attempts_per_user
|
||||
template.times_per_day = times_per_day
|
||||
@@ -189,6 +191,22 @@ async def create_attempt(
|
||||
return attempt
|
||||
|
||||
|
||||
async def update_attempt(
|
||||
db: AsyncSession,
|
||||
attempt: ContestAttempt,
|
||||
*,
|
||||
answer: Optional[str] = None,
|
||||
is_winner: bool = False,
|
||||
) -> ContestAttempt:
|
||||
"""Update existing attempt with answer and winner status."""
|
||||
if answer is not None:
|
||||
attempt.answer = answer
|
||||
attempt.is_winner = is_winner
|
||||
await db.commit()
|
||||
await db.refresh(attempt)
|
||||
return attempt
|
||||
|
||||
|
||||
async def clear_attempts(db: AsyncSession, round_id: int) -> int:
|
||||
result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id))
|
||||
deleted_count = result.rowcount
|
||||
|
||||
@@ -269,6 +269,176 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def get_top_referrers_by_period(
|
||||
db: AsyncSession,
|
||||
period: str = "week", # "week" или "month"
|
||||
sort_by: str = "earnings", # "earnings" или "invited"
|
||||
limit: int = 20
|
||||
) -> list:
|
||||
"""
|
||||
Получает топ рефереров за период.
|
||||
|
||||
Args:
|
||||
period: "week" (7 дней) или "month" (30 дней)
|
||||
sort_by: "earnings" (по заработку) или "invited" (по приглашённым)
|
||||
limit: количество записей
|
||||
|
||||
Returns:
|
||||
Список словарей с данными рефереров
|
||||
"""
|
||||
from app.database.models import Transaction, TransactionType
|
||||
|
||||
now = datetime.utcnow()
|
||||
if period == "week":
|
||||
start_date = now - timedelta(days=7)
|
||||
else: # month
|
||||
start_date = now - timedelta(days=30)
|
||||
|
||||
if sort_by == "invited":
|
||||
# Топ по количеству приглашённых за период
|
||||
referrals_result = await db.execute(
|
||||
select(
|
||||
User.referred_by_id.label('referrer_id'),
|
||||
func.count(User.id).label('invited_count')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id.isnot(None),
|
||||
User.created_at >= start_date
|
||||
)
|
||||
)
|
||||
.group_by(User.referred_by_id)
|
||||
.order_by(func.count(User.id).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
top_data = []
|
||||
for row in referrals_result:
|
||||
# Получаем заработок за период для этого реферера
|
||||
earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
ReferralEarning.user_id == row.referrer_id,
|
||||
ReferralEarning.created_at >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
earnings = earnings_result.scalar() or 0
|
||||
|
||||
# Добавляем транзакции REFERRAL_REWARD
|
||||
trans_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id == row.referrer_id,
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
earnings += trans_earnings_result.scalar() or 0
|
||||
|
||||
top_data.append({
|
||||
'referrer_id': row.referrer_id,
|
||||
'invited_count': row.invited_count,
|
||||
'earnings_kopeks': earnings
|
||||
})
|
||||
else:
|
||||
# Топ по заработку за период
|
||||
# Собираем заработки из ReferralEarning
|
||||
referral_earnings_result = await db.execute(
|
||||
select(
|
||||
ReferralEarning.user_id.label('referrer_id'),
|
||||
func.sum(ReferralEarning.amount_kopeks).label('ref_earnings')
|
||||
)
|
||||
.where(ReferralEarning.created_at >= start_date)
|
||||
.group_by(ReferralEarning.user_id)
|
||||
)
|
||||
referral_earnings = {row.referrer_id: row.ref_earnings for row in referral_earnings_result}
|
||||
|
||||
# Добавляем транзакции REFERRAL_REWARD
|
||||
transaction_earnings_result = await db.execute(
|
||||
select(
|
||||
Transaction.user_id.label('referrer_id'),
|
||||
func.sum(Transaction.amount_kopeks).label('trans_earnings')
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.REFERRAL_REWARD.value,
|
||||
Transaction.created_at >= start_date
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
|
||||
# Объединяем заработки
|
||||
combined_earnings = dict(referral_earnings)
|
||||
for row in transaction_earnings_result:
|
||||
if row.referrer_id in combined_earnings:
|
||||
combined_earnings[row.referrer_id] += row.trans_earnings or 0
|
||||
else:
|
||||
combined_earnings[row.referrer_id] = row.trans_earnings or 0
|
||||
|
||||
# Сортируем и берём топ
|
||||
sorted_referrers = sorted(
|
||||
combined_earnings.items(),
|
||||
key=lambda x: x[1],
|
||||
reverse=True
|
||||
)[:limit]
|
||||
|
||||
top_data = []
|
||||
for referrer_id, earnings in sorted_referrers:
|
||||
# Получаем количество приглашённых за период
|
||||
invited_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(
|
||||
and_(
|
||||
User.referred_by_id == referrer_id,
|
||||
User.created_at >= start_date
|
||||
)
|
||||
)
|
||||
)
|
||||
invited_count = invited_result.scalar() or 0
|
||||
|
||||
top_data.append({
|
||||
'referrer_id': referrer_id,
|
||||
'invited_count': invited_count,
|
||||
'earnings_kopeks': earnings
|
||||
})
|
||||
|
||||
# Добавляем информацию о пользователях
|
||||
result = []
|
||||
for data in top_data:
|
||||
user_result = await db.execute(
|
||||
select(User.id, User.username, User.first_name, User.last_name, User.telegram_id)
|
||||
.where(User.id == data['referrer_id'])
|
||||
)
|
||||
user = user_result.first()
|
||||
|
||||
if user:
|
||||
display_name = ""
|
||||
if user.first_name:
|
||||
display_name = user.first_name
|
||||
if user.last_name:
|
||||
display_name += f" {user.last_name}"
|
||||
elif user.username:
|
||||
display_name = f"@{user.username}"
|
||||
else:
|
||||
display_name = f"ID{user.telegram_id}"
|
||||
|
||||
result.append({
|
||||
'user_id': user.id,
|
||||
'telegram_id': user.telegram_id,
|
||||
'username': user.username,
|
||||
'display_name': display_name,
|
||||
'invited_count': data['invited_count'],
|
||||
'earnings_kopeks': data['earnings_kopeks']
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
|
||||
invited_count_result = await db.execute(
|
||||
|
||||
@@ -179,10 +179,20 @@ async def get_contest_leaderboard(
|
||||
*,
|
||||
limit: Optional[int] = None,
|
||||
) -> Sequence[Tuple[User, int, int]]:
|
||||
"""Получить лидерборд конкурса.
|
||||
|
||||
Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса.
|
||||
"""
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
return []
|
||||
|
||||
# Нормализуем границы дат
|
||||
contest_start = contest.start_at
|
||||
contest_end = contest.end_at
|
||||
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
|
||||
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
query = (
|
||||
select(
|
||||
User,
|
||||
@@ -190,7 +200,13 @@ async def get_contest_leaderboard(
|
||||
func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label("total_amount"),
|
||||
)
|
||||
.join(User, User.id == ReferralContestEvent.referrer_id)
|
||||
.where(ReferralContestEvent.contest_id == contest_id)
|
||||
.where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.occurred_at >= contest_start,
|
||||
ReferralContestEvent.occurred_at <= contest_end,
|
||||
)
|
||||
)
|
||||
.group_by(User.id)
|
||||
.order_by(desc("referral_count"), desc("total_amount"), User.id)
|
||||
)
|
||||
@@ -206,10 +222,29 @@ async def get_contest_participants(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> Sequence[Tuple[User, int]]:
|
||||
"""Получить участников конкурса.
|
||||
|
||||
Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса.
|
||||
"""
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
return []
|
||||
|
||||
contest_start = contest.start_at
|
||||
contest_end = contest.end_at
|
||||
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
|
||||
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
result = await db.execute(
|
||||
select(User, func.count(ReferralContestEvent.id).label("referral_count"))
|
||||
.join(User, User.id == ReferralContestEvent.referrer_id)
|
||||
.where(ReferralContestEvent.contest_id == contest_id)
|
||||
.where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.occurred_at >= contest_start,
|
||||
ReferralContestEvent.occurred_at <= contest_end,
|
||||
)
|
||||
)
|
||||
.group_by(User.id)
|
||||
)
|
||||
return result.all()
|
||||
@@ -297,3 +332,512 @@ async def delete_referral_contest(
|
||||
) -> None:
|
||||
await db.delete(contest)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_contest_payment_stats(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> dict:
|
||||
"""Получить статистику оплат по конкурсу.
|
||||
|
||||
Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса.
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"paid_count": int, # Рефералов с платежами > 0
|
||||
"unpaid_count": int, # Рефералов без платежей
|
||||
"total_amount": int, # Общая сумма платежей
|
||||
}
|
||||
"""
|
||||
# Получаем даты конкурса для фильтрации
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
return {"paid_count": 0, "unpaid_count": 0, "total_amount": 0}
|
||||
|
||||
contest_start = contest.start_at
|
||||
contest_end = contest.end_at
|
||||
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
|
||||
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
# Считаем рефералов с платежами (только зарегистрированных в период конкурса)
|
||||
paid_result = await db.execute(
|
||||
select(func.count(ReferralContestEvent.id))
|
||||
.where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.amount_kopeks > 0,
|
||||
ReferralContestEvent.occurred_at >= contest_start,
|
||||
ReferralContestEvent.occurred_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
paid_count = int(paid_result.scalar_one() or 0)
|
||||
|
||||
# Считаем рефералов без платежей (только зарегистрированных в период конкурса)
|
||||
unpaid_result = await db.execute(
|
||||
select(func.count(ReferralContestEvent.id))
|
||||
.where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.amount_kopeks == 0,
|
||||
ReferralContestEvent.occurred_at >= contest_start,
|
||||
ReferralContestEvent.occurred_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
unpaid_count = int(unpaid_result.scalar_one() or 0)
|
||||
|
||||
# Общая сумма (только за рефералов зарегистрированных в период конкурса)
|
||||
total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.occurred_at >= contest_start,
|
||||
ReferralContestEvent.occurred_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
total_amount = int(total_result.scalar_one() or 0)
|
||||
|
||||
return {
|
||||
"paid_count": paid_count,
|
||||
"unpaid_count": unpaid_count,
|
||||
"total_amount": total_amount,
|
||||
}
|
||||
|
||||
|
||||
async def get_contest_transaction_breakdown(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> dict:
|
||||
"""Получить разбивку транзакций по типам для конкурса.
|
||||
|
||||
Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса.
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"subscription_total": int, # Сумма покупок подписок (копейки)
|
||||
"deposit_total": int, # Сумма пополнений баланса (копейки)
|
||||
}
|
||||
"""
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
return {"subscription_total": 0, "deposit_total": 0}
|
||||
|
||||
contest_start = contest.start_at
|
||||
contest_end = contest.end_at
|
||||
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
|
||||
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
# Получаем referral_id только из событий в период конкурса
|
||||
events_result = await db.execute(
|
||||
select(ReferralContestEvent.referral_id)
|
||||
.where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.occurred_at >= contest_start,
|
||||
ReferralContestEvent.occurred_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
referral_ids = [r[0] for r in events_result.fetchall()]
|
||||
|
||||
if not referral_ids:
|
||||
return {"subscription_total": 0, "deposit_total": 0}
|
||||
|
||||
# Сумма покупок подписок
|
||||
subscription_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.created_at >= contest_start,
|
||||
Transaction.created_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
subscription_total = int(subscription_result.scalar_one() or 0)
|
||||
|
||||
# Сумма пополнений баланса
|
||||
deposit_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.created_at >= contest_start,
|
||||
Transaction.created_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
deposit_total = int(deposit_result.scalar_one() or 0)
|
||||
|
||||
return {
|
||||
"subscription_total": subscription_total,
|
||||
"deposit_total": deposit_total,
|
||||
}
|
||||
|
||||
|
||||
async def upsert_contest_event(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
contest_id: int,
|
||||
referrer_id: int,
|
||||
referral_id: int,
|
||||
amount_kopeks: int = 0,
|
||||
event_type: str = "subscription_purchase",
|
||||
) -> Tuple[ReferralContestEvent, bool]:
|
||||
"""Создать или обновить событие конкурса.
|
||||
|
||||
Returns:
|
||||
Tuple[ReferralContestEvent, bool]: (событие, создано_новое)
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ReferralContestEvent).where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.referral_id == referral_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Обновляем сумму если она изменилась
|
||||
if existing.amount_kopeks != amount_kopeks:
|
||||
existing.amount_kopeks = amount_kopeks
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return existing, False
|
||||
|
||||
event = ReferralContestEvent(
|
||||
contest_id=contest_id,
|
||||
referrer_id=referrer_id,
|
||||
referral_id=referral_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
event_type=event_type,
|
||||
occurred_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
await db.refresh(event)
|
||||
return event, True
|
||||
|
||||
|
||||
async def debug_contest_transactions(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
limit: int = 20,
|
||||
) -> dict:
|
||||
"""Показать транзакции которые учитываются в конкурсе для отладки.
|
||||
|
||||
Возвращает информацию о транзакциях рефералов конкурса,
|
||||
чтобы понять какие именно платежи считаются.
|
||||
"""
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
return {"error": "Contest not found"}
|
||||
|
||||
# Нормализуем границы дат
|
||||
contest_start = contest.start_at
|
||||
contest_end = contest.end_at
|
||||
|
||||
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
|
||||
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
# Получаем referral_id ТОЛЬКО из событий которые произошли в период конкурса
|
||||
events_result = await db.execute(
|
||||
select(ReferralContestEvent.referral_id)
|
||||
.where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.occurred_at >= contest_start,
|
||||
ReferralContestEvent.occurred_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
referral_ids = [r[0] for r in events_result.fetchall()]
|
||||
|
||||
# Также считаем сколько всего событий для сравнения
|
||||
all_events_result = await db.execute(
|
||||
select(func.count(ReferralContestEvent.id))
|
||||
.where(ReferralContestEvent.contest_id == contest_id)
|
||||
)
|
||||
total_all_events = int(all_events_result.scalar_one() or 0)
|
||||
|
||||
if not referral_ids:
|
||||
return {
|
||||
"contest_start": contest_start.isoformat(),
|
||||
"contest_end": contest_end.isoformat(),
|
||||
"referral_count": 0,
|
||||
"total_all_events": total_all_events,
|
||||
"transactions": [],
|
||||
}
|
||||
|
||||
# Получаем транзакции этих рефералов ЗА период конкурса
|
||||
transactions_in_period = await db.execute(
|
||||
select(Transaction)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type.in_([
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]),
|
||||
Transaction.created_at >= contest_start,
|
||||
Transaction.created_at <= contest_end,
|
||||
)
|
||||
)
|
||||
.order_by(desc(Transaction.created_at))
|
||||
.limit(limit)
|
||||
)
|
||||
txs_in = transactions_in_period.scalars().all()
|
||||
|
||||
# Также получаем транзакции ВНЕ периода для сравнения
|
||||
transactions_outside = await db.execute(
|
||||
select(Transaction)
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type.in_([
|
||||
TransactionType.DEPOSIT.value,
|
||||
TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
]),
|
||||
func.not_(
|
||||
and_(
|
||||
Transaction.created_at >= contest_start,
|
||||
Transaction.created_at <= contest_end,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
.order_by(desc(Transaction.created_at))
|
||||
.limit(limit)
|
||||
)
|
||||
txs_out = transactions_outside.scalars().all()
|
||||
|
||||
# Подсчёт общих сумм ПО ТИПАМ
|
||||
deposit_in_period = sum(tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.DEPOSIT.value)
|
||||
subscription_in_period = sum(tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value)
|
||||
total_in_period = deposit_in_period + subscription_in_period
|
||||
total_outside = sum(tx.amount_kopeks for tx in txs_out)
|
||||
|
||||
# Подсчёт ПОЛНЫХ сумм (не только sample)
|
||||
full_deposit_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.created_at >= contest_start,
|
||||
Transaction.created_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
full_deposit_total = int(full_deposit_result.scalar_one() or 0)
|
||||
|
||||
full_subscription_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.created_at >= contest_start,
|
||||
Transaction.created_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
full_subscription_total = int(full_subscription_result.scalar_one() or 0)
|
||||
|
||||
return {
|
||||
"contest_start": contest_start.isoformat(),
|
||||
"contest_end": contest_end.isoformat(),
|
||||
"referral_count": len(referral_ids),
|
||||
"total_all_events": total_all_events,
|
||||
"filtered_out": total_all_events - len(referral_ids),
|
||||
"transactions_in_period": [
|
||||
{
|
||||
"id": tx.id,
|
||||
"user_id": tx.user_id,
|
||||
"type": tx.type,
|
||||
"amount_kopeks": tx.amount_kopeks,
|
||||
"created_at": tx.created_at.isoformat() if tx.created_at else None,
|
||||
"payment_method": tx.payment_method,
|
||||
}
|
||||
for tx in txs_in
|
||||
],
|
||||
"transactions_outside_period": [
|
||||
{
|
||||
"id": tx.id,
|
||||
"user_id": tx.user_id,
|
||||
"type": tx.type,
|
||||
"amount_kopeks": tx.amount_kopeks,
|
||||
"created_at": tx.created_at.isoformat() if tx.created_at else None,
|
||||
"payment_method": tx.payment_method,
|
||||
}
|
||||
for tx in txs_out
|
||||
],
|
||||
"total_in_period_kopeks": total_in_period,
|
||||
"total_outside_period_kopeks": total_outside,
|
||||
"deposit_total_kopeks": full_deposit_total,
|
||||
"subscription_total_kopeks": full_subscription_total,
|
||||
"sample_size": limit,
|
||||
}
|
||||
|
||||
|
||||
async def sync_contest_events(
|
||||
db: AsyncSession,
|
||||
contest_id: int,
|
||||
) -> dict:
|
||||
"""Синхронизировать события конкурса с реальными данными.
|
||||
|
||||
Обновляет ВСЕ существующие события конкурса, пересчитывая платежи
|
||||
каждого реферала СТРОГО за период конкурса (start_at - end_at).
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"updated": int, # Событий обновлено
|
||||
"skipped": int, # Пропущено (нет изменений)
|
||||
"total_events": int, # Всего событий проверено
|
||||
"total_amount": int, # Общая сумма платежей
|
||||
"paid_count": int, # Рефералов с платежами
|
||||
"unpaid_count": int, # Рефералов без платежей
|
||||
}
|
||||
"""
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
if not contest:
|
||||
return {"error": "Contest not found"}
|
||||
|
||||
# Нормализуем границы дат для СТРОГОЙ фильтрации
|
||||
# start_at должен быть началом дня (00:00:00)
|
||||
# end_at должен быть концом дня (23:59:59.999999)
|
||||
contest_start = contest.start_at
|
||||
contest_end = contest.end_at
|
||||
|
||||
# Если start_at содержит только дату (время 00:00), оставляем как есть
|
||||
# Если end_at содержит только дату, добавляем время до конца дня
|
||||
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
|
||||
# Конец дня: 23:59:59.999999
|
||||
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
logger.info(
|
||||
"Синхронизация конкурса %s: период с %s по %s",
|
||||
contest_id, contest_start, contest_end
|
||||
)
|
||||
|
||||
stats = {
|
||||
"updated": 0,
|
||||
"skipped": 0,
|
||||
"total_events": 0,
|
||||
"total_amount": 0,
|
||||
"paid_count": 0,
|
||||
"unpaid_count": 0,
|
||||
"contest_start": contest_start.isoformat(),
|
||||
"contest_end": contest_end.isoformat(),
|
||||
}
|
||||
|
||||
# Получаем события конкурса ТОЛЬКО те, что произошли в период конкурса
|
||||
# (реферал зарегистрировался в период проведения конкурса)
|
||||
events_result = await db.execute(
|
||||
select(ReferralContestEvent)
|
||||
.where(
|
||||
and_(
|
||||
ReferralContestEvent.contest_id == contest_id,
|
||||
ReferralContestEvent.occurred_at >= contest_start,
|
||||
ReferralContestEvent.occurred_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
events = events_result.scalars().all()
|
||||
|
||||
# Также считаем сколько всего событий (для отладки)
|
||||
all_events_result = await db.execute(
|
||||
select(func.count(ReferralContestEvent.id))
|
||||
.where(ReferralContestEvent.contest_id == contest_id)
|
||||
)
|
||||
total_all_events = int(all_events_result.scalar_one() or 0)
|
||||
|
||||
stats["total_events"] = len(events)
|
||||
stats["total_all_events"] = total_all_events
|
||||
stats["filtered_out_events"] = total_all_events - len(events)
|
||||
|
||||
stats["deposit_total"] = 0
|
||||
stats["subscription_total"] = 0
|
||||
|
||||
for event in events:
|
||||
# Считаем ТОЛЬКО покупки подписок (реальные траты на подписки)
|
||||
subscription_query = (
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id == event.referral_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
Transaction.created_at >= contest_start,
|
||||
Transaction.created_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
sub_result = await db.execute(subscription_query)
|
||||
subscription_paid = int(sub_result.scalar_one() or 0)
|
||||
|
||||
# Также считаем пополнения баланса (для информации)
|
||||
deposit_query = (
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id == event.referral_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.created_at >= contest_start,
|
||||
Transaction.created_at <= contest_end,
|
||||
)
|
||||
)
|
||||
)
|
||||
dep_result = await db.execute(deposit_query)
|
||||
deposit_paid = int(dep_result.scalar_one() or 0)
|
||||
|
||||
stats["subscription_total"] += subscription_paid
|
||||
stats["deposit_total"] += deposit_paid
|
||||
|
||||
# Основная метрика — покупки подписок
|
||||
total_paid = subscription_paid
|
||||
|
||||
# Считаем статистику
|
||||
if total_paid > 0:
|
||||
stats["total_amount"] += total_paid
|
||||
stats["paid_count"] += 1
|
||||
else:
|
||||
stats["unpaid_count"] += 1
|
||||
|
||||
# Обновляем сумму если изменилась
|
||||
if event.amount_kopeks != total_paid:
|
||||
old_amount = event.amount_kopeks
|
||||
event.amount_kopeks = total_paid
|
||||
stats["updated"] += 1
|
||||
# Логируем значительные изменения
|
||||
if abs(old_amount - total_paid) > 10000: # больше 100 руб разницы
|
||||
logger.debug(
|
||||
"Событие %s (реферал %s): %s -> %s коп.",
|
||||
event.id, event.referral_id, old_amount, total_paid
|
||||
)
|
||||
else:
|
||||
stats["skipped"] += 1
|
||||
|
||||
# Сохраняем изменения
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Синхронизация конкурса %s завершена: обновлено %s, пропущено %s, сумма %s коп.",
|
||||
contest_id, stats["updated"], stats["skipped"], stats["total_amount"]
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
@@ -128,17 +128,18 @@ async def create_paid_subscription(
|
||||
device_limit: Optional[int] = None,
|
||||
connected_squads: List[str] = None,
|
||||
update_server_counters: bool = False,
|
||||
is_trial: bool = False,
|
||||
) -> Subscription:
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
|
||||
if device_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
is_trial=False,
|
||||
is_trial=is_trial,
|
||||
start_date=datetime.utcnow(),
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
@@ -222,6 +223,7 @@ async def replace_subscription(
|
||||
subscription.end_date = current_time + timedelta(days=duration_days)
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
subscription.traffic_used_gb = 0.0
|
||||
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки
|
||||
subscription.device_limit = device_limit
|
||||
subscription.connected_squads = list(new_squads)
|
||||
subscription.subscription_url = None
|
||||
@@ -339,7 +341,17 @@ async def extend_subscription(
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
|
||||
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик вместе с использованным
|
||||
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
|
||||
|
||||
# В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита
|
||||
if settings.is_traffic_fixed() and days > 0:
|
||||
fixed_limit = settings.get_fixed_traffic_limit()
|
||||
old_limit = subscription.traffic_limit_gb
|
||||
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:
|
||||
subscription.traffic_limit_gb = fixed_limit
|
||||
subscription.purchased_traffic_gb = 0
|
||||
logger.info(f"🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ")
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
@@ -797,17 +809,17 @@ async def update_subscription_usage(
|
||||
return subscription
|
||||
|
||||
async def get_all_subscriptions(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
limit: int = 10
|
||||
) -> Tuple[List[Subscription], int]:
|
||||
count_result = await db.execute(
|
||||
select(func.count(Subscription.id))
|
||||
)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
@@ -815,11 +827,27 @@ async def get_all_subscriptions(
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
|
||||
return subscriptions, total_count
|
||||
|
||||
|
||||
async def get_subscriptions_batch(
|
||||
db: AsyncSession,
|
||||
offset: int = 0,
|
||||
limit: int = 500,
|
||||
) -> List[Subscription]:
|
||||
"""Получает подписки пачками для синхронизации. Загружает связанных пользователей."""
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(selectinload(Subscription.user))
|
||||
.order_by(Subscription.id)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def add_subscription_servers(
|
||||
db: AsyncSession,
|
||||
subscription: Subscription,
|
||||
@@ -1139,7 +1167,12 @@ async def get_subscription_renewal_cost(
|
||||
total_servers_cost = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
# В режиме fixed_with_topup при продлении используем фиксированный лимит
|
||||
if settings.is_traffic_fixed():
|
||||
renewal_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
renewal_traffic_gb = subscription.traffic_limit_gb
|
||||
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
@@ -1529,7 +1562,84 @@ async def create_pending_subscription(
|
||||
subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
async def create_pending_trial_subscription(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
duration_days: int,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
connected_squads: List[str] = None,
|
||||
payment_method: str = "pending",
|
||||
total_price_kopeks: int = 0
|
||||
) -> Subscription:
|
||||
"""Creates a pending trial subscription that will be activated after payment."""
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
end_date = current_time + timedelta(days=duration_days)
|
||||
|
||||
existing_subscription = await get_subscription_by_user_id(db, user_id)
|
||||
|
||||
if existing_subscription:
|
||||
if (
|
||||
existing_subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and existing_subscription.end_date > current_time
|
||||
):
|
||||
logger.warning(
|
||||
"⚠️ Попытка создать pending триал для активного пользователя %s. Возвращаем существующую запись.",
|
||||
user_id,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
# Обновляем существующую подписку
|
||||
existing_subscription.status = SubscriptionStatus.PENDING.value
|
||||
existing_subscription.is_trial = True # Помечаем как триальную
|
||||
existing_subscription.start_date = current_time
|
||||
existing_subscription.end_date = end_date
|
||||
existing_subscription.traffic_limit_gb = traffic_limit_gb
|
||||
existing_subscription.device_limit = device_limit
|
||||
existing_subscription.connected_squads = connected_squads or []
|
||||
existing_subscription.traffic_used_gb = 0.0
|
||||
existing_subscription.updated_at = current_time
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
logger.info(
|
||||
"♻️ Обновлена ожидающая триальная подписка пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
existing_subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.PENDING.value,
|
||||
is_trial=True, # Помечаем как триальную
|
||||
start_date=current_time,
|
||||
end_date=end_date,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads or [],
|
||||
autopay_enabled=settings.is_autopay_enabled_by_default(),
|
||||
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
|
||||
)
|
||||
|
||||
db.add(subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
"💳 Создана ожидающая триальная подписка для пользователя %s, ID: %s, метод оплаты: %s",
|
||||
user_id,
|
||||
subscription.id,
|
||||
payment_method,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
|
||||
@@ -331,13 +331,14 @@ async def add_user_balance(
|
||||
description: str = "Пополнение баланса",
|
||||
create_transaction: bool = True,
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
bot = None
|
||||
bot = None,
|
||||
payment_method: Optional[PaymentMethod] = None
|
||||
) -> bool:
|
||||
try:
|
||||
old_balance = user.balance_kopeks
|
||||
user.balance_kopeks += amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import create_transaction as create_trans
|
||||
|
||||
@@ -346,7 +347,8 @@ async def add_user_balance(
|
||||
user_id=user.id,
|
||||
type=transaction_type,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description
|
||||
description=description,
|
||||
payment_method=payment_method
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
@@ -368,19 +370,21 @@ async def add_user_balance_by_id(
|
||||
amount_kopeks: int,
|
||||
description: str = "Пополнение баланса",
|
||||
transaction_type: TransactionType = TransactionType.DEPOSIT,
|
||||
payment_method: Optional[PaymentMethod] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user:
|
||||
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
|
||||
return False
|
||||
|
||||
|
||||
return await add_user_balance(
|
||||
db,
|
||||
user,
|
||||
amount_kopeks,
|
||||
description,
|
||||
transaction_type=transaction_type,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+230
-66
@@ -1,26 +1,39 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import AsyncGenerator, Optional
|
||||
from functools import wraps
|
||||
from typing import AsyncGenerator, Callable, Optional, TypeVar
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
create_async_engine,
|
||||
AsyncSession,
|
||||
create_async_engine,
|
||||
async_sessionmaker,
|
||||
AsyncEngine
|
||||
)
|
||||
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
|
||||
from sqlalchemy import event, text, bindparam, inspect
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.exc import OperationalError, InterfaceError
|
||||
import time
|
||||
from app.config import settings
|
||||
from app.database.models import Base
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# ============================================================================
|
||||
# PRODUCTION-GRADE CONNECTION POOLING
|
||||
# ============================================================================
|
||||
|
||||
if settings.get_database_url().startswith("sqlite"):
|
||||
def _is_sqlite_url(url: str) -> bool:
|
||||
"""Проверка на SQLite URL (поддерживает sqlite:// и sqlite+aiosqlite://)"""
|
||||
return url.startswith("sqlite") or ":memory:" in url
|
||||
|
||||
|
||||
DATABASE_URL = settings.get_database_url()
|
||||
IS_SQLITE = _is_sqlite_url(DATABASE_URL)
|
||||
|
||||
if IS_SQLITE:
|
||||
poolclass = NullPool
|
||||
pool_kwargs = {}
|
||||
else:
|
||||
@@ -31,7 +44,7 @@ else:
|
||||
"pool_timeout": 30,
|
||||
"pool_recycle": 3600,
|
||||
"pool_pre_ping": True,
|
||||
# 🔥 Агрессивная очистка мертвых соединений
|
||||
# Агрессивная очистка мертвых соединений
|
||||
"pool_reset_on_return": "rollback",
|
||||
}
|
||||
|
||||
@@ -39,28 +52,30 @@ else:
|
||||
# ENGINE WITH ADVANCED OPTIMIZATIONS
|
||||
# ============================================================================
|
||||
|
||||
# PostgreSQL-специфичные connect_args
|
||||
_pg_connect_args = {
|
||||
"server_settings": {
|
||||
"application_name": "remnawave_bot",
|
||||
"jit": "on",
|
||||
"statement_timeout": "60000", # 60 секунд
|
||||
"idle_in_transaction_session_timeout": "300000", # 5 минут
|
||||
},
|
||||
"command_timeout": 60,
|
||||
"timeout": 10,
|
||||
}
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.get_database_url(),
|
||||
DATABASE_URL,
|
||||
poolclass=poolclass,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
**pool_kwargs,
|
||||
|
||||
connect_args={
|
||||
"server_settings": {
|
||||
"application_name": "remnawave_bot",
|
||||
"jit": "on",
|
||||
"statement_timeout": "60000", # 60 секунд
|
||||
"idle_in_transaction_session_timeout": "300000", # 5 минут
|
||||
},
|
||||
"command_timeout": 60,
|
||||
"timeout": 10,
|
||||
} if not settings.get_database_url().startswith("sqlite") else {},
|
||||
|
||||
# Кеш скомпилированных запросов (правильное размещение)
|
||||
query_cache_size=500,
|
||||
connect_args=_pg_connect_args if not IS_SQLITE else {},
|
||||
execution_options={
|
||||
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
|
||||
"compiled_cache_size": 500, # Кеш скомпилированных запросов
|
||||
}
|
||||
"isolation_level": "READ COMMITTED",
|
||||
},
|
||||
**pool_kwargs,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
@@ -71,10 +86,86 @@ AsyncSessionLocal = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False, # 🔥 Критично для производительности
|
||||
autoflush=False, # Критично для производительности
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# RETRY LOGIC FOR DATABASE OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
RETRYABLE_EXCEPTIONS = (OperationalError, InterfaceError, ConnectionRefusedError, OSError)
|
||||
DEFAULT_RETRY_ATTEMPTS = 3
|
||||
DEFAULT_RETRY_DELAY = 0.5 # секунды
|
||||
|
||||
|
||||
def with_db_retry(
|
||||
attempts: int = DEFAULT_RETRY_ATTEMPTS,
|
||||
delay: float = DEFAULT_RETRY_DELAY,
|
||||
backoff: float = 2.0,
|
||||
) -> Callable:
|
||||
"""
|
||||
Декоратор для автоматического retry при сбоях подключения к БД.
|
||||
|
||||
Args:
|
||||
attempts: Количество попыток
|
||||
delay: Начальная задержка между попытками (секунды)
|
||||
backoff: Множитель задержки для каждой следующей попытки
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
last_exception = None
|
||||
current_delay = delay
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except RETRYABLE_EXCEPTIONS as e:
|
||||
last_exception = e
|
||||
if attempt < attempts:
|
||||
logger.warning(
|
||||
"Ошибка БД (попытка %d/%d): %s. Повтор через %.1f сек...",
|
||||
attempt, attempts, str(e)[:100], current_delay
|
||||
)
|
||||
await asyncio.sleep(current_delay)
|
||||
current_delay *= backoff
|
||||
else:
|
||||
logger.error(
|
||||
"Ошибка БД: все %d попыток исчерпаны. Последняя ошибка: %s",
|
||||
attempts, str(e)
|
||||
)
|
||||
|
||||
raise last_exception
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
async def execute_with_retry(
|
||||
session: AsyncSession,
|
||||
statement,
|
||||
attempts: int = DEFAULT_RETRY_ATTEMPTS,
|
||||
):
|
||||
"""Выполнение SQL с retry логикой."""
|
||||
last_exception = None
|
||||
delay = DEFAULT_RETRY_DELAY
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
return await session.execute(statement)
|
||||
except RETRYABLE_EXCEPTIONS as e:
|
||||
last_exception = e
|
||||
if attempt < attempts:
|
||||
logger.warning(
|
||||
"SQL retry (попытка %d/%d): %s",
|
||||
attempt, attempts, str(e)[:100]
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
|
||||
raise last_exception
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# QUERY PERFORMANCE MONITORING
|
||||
# ============================================================================
|
||||
@@ -97,35 +188,68 @@ if settings.DEBUG:
|
||||
# ADVANCED SESSION MANAGER WITH READ REPLICAS
|
||||
# ============================================================================
|
||||
|
||||
HEALTH_CHECK_TIMEOUT = 5.0 # секунды
|
||||
|
||||
|
||||
def _validate_database_url(url: Optional[str]) -> Optional[str]:
|
||||
"""Валидация URL базы данных."""
|
||||
if not url:
|
||||
return None
|
||||
url = url.strip()
|
||||
if not url or url.isspace():
|
||||
return None
|
||||
# Простая проверка на валидный формат
|
||||
if not ("://" in url or url.startswith("sqlite")):
|
||||
logger.warning("Невалидный DATABASE_URL: %s", url[:20])
|
||||
return None
|
||||
return url
|
||||
|
||||
|
||||
class DatabaseManager:
|
||||
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self.engine = engine
|
||||
self.read_replica_engine: Optional[AsyncEngine] = None
|
||||
|
||||
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
|
||||
self.read_replica_engine = create_async_engine(
|
||||
settings.DATABASE_READ_REPLICA_URL,
|
||||
poolclass=poolclass,
|
||||
pool_size=30, # Больше для read операций
|
||||
max_overflow=50,
|
||||
pool_pre_ping=True,
|
||||
echo=False,
|
||||
)
|
||||
|
||||
self._read_replica_session_factory: Optional[async_sessionmaker] = None
|
||||
|
||||
# Валидация и создание read replica engine
|
||||
replica_url = _validate_database_url(
|
||||
getattr(settings, 'DATABASE_READ_REPLICA_URL', None)
|
||||
)
|
||||
if replica_url:
|
||||
try:
|
||||
self.read_replica_engine = create_async_engine(
|
||||
replica_url,
|
||||
poolclass=poolclass,
|
||||
pool_size=30, # Больше для read операций
|
||||
max_overflow=50,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
echo=False,
|
||||
)
|
||||
# Создаём sessionmaker один раз (не при каждом вызове)
|
||||
self._read_replica_session_factory = async_sessionmaker(
|
||||
bind=self.read_replica_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
logger.info("Read replica настроена: %s", replica_url[:30] + "...")
|
||||
except Exception as e:
|
||||
logger.error("Не удалось настроить read replica: %s", e)
|
||||
self.read_replica_engine = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def session(self, read_only: bool = False):
|
||||
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
bind=target_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
"""Контекстный менеджер для работы с сессией БД."""
|
||||
# Используем предсозданный sessionmaker вместо создания нового
|
||||
if read_only and self._read_replica_session_factory:
|
||||
session_factory = self._read_replica_session_factory
|
||||
else:
|
||||
session_factory = AsyncSessionLocal
|
||||
|
||||
async with session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
if not read_only:
|
||||
@@ -133,20 +257,31 @@ class DatabaseManager:
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
async def health_check(self) -> dict:
|
||||
|
||||
async def health_check(self, timeout: float = HEALTH_CHECK_TIMEOUT) -> dict:
|
||||
"""
|
||||
Проверка здоровья БД с таймаутом.
|
||||
|
||||
Args:
|
||||
timeout: Максимальное время ожидания (секунды)
|
||||
"""
|
||||
pool = self.engine.pool
|
||||
status = "unhealthy"
|
||||
latency = None
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
start = time.time()
|
||||
await session.execute(text("SELECT 1"))
|
||||
latency = (time.time() - start) * 1000
|
||||
async with asyncio.timeout(timeout):
|
||||
async with AsyncSessionLocal() as session:
|
||||
start = time.time()
|
||||
await session.execute(text("SELECT 1"))
|
||||
latency = (time.time() - start) * 1000
|
||||
status = "healthy"
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Health check таймаут (%s сек)", timeout)
|
||||
status = "timeout"
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Database health check failed: {e}")
|
||||
logger.error("Database health check failed: %s", e)
|
||||
status = "unhealthy"
|
||||
latency = None
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
@@ -154,6 +289,34 @@ class DatabaseManager:
|
||||
"pool": _collect_health_pool_metrics(pool),
|
||||
}
|
||||
|
||||
async def health_check_replica(self, timeout: float = HEALTH_CHECK_TIMEOUT) -> Optional[dict]:
|
||||
"""Проверка здоровья read replica."""
|
||||
if not self.read_replica_engine:
|
||||
return None
|
||||
|
||||
pool = self.read_replica_engine.pool
|
||||
status = "unhealthy"
|
||||
latency = None
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
async with self._read_replica_session_factory() as session:
|
||||
start = time.time()
|
||||
await session.execute(text("SELECT 1"))
|
||||
latency = (time.time() - start) * 1000
|
||||
status = "healthy"
|
||||
except asyncio.TimeoutError:
|
||||
status = "timeout"
|
||||
except Exception as e:
|
||||
logger.error("Read replica health check failed: %s", e)
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"latency_ms": round(latency, 2) if latency else None,
|
||||
"pool": _collect_health_pool_metrics(pool),
|
||||
}
|
||||
|
||||
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
# ============================================================================
|
||||
@@ -254,13 +417,13 @@ batch_ops = BatchOperations()
|
||||
|
||||
async def init_db():
|
||||
"""Инициализация БД с оптимизациями"""
|
||||
logger.info("🚀 Создание таблиц базы данных...")
|
||||
|
||||
logger.info("Создание таблиц базы данных...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
if not settings.get_database_url().startswith("sqlite"):
|
||||
logger.info("📊 Создание индексов для оптимизации...")
|
||||
if not IS_SQLITE:
|
||||
logger.info("Создание индексов для оптимизации...")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
indexes = [
|
||||
@@ -294,22 +457,23 @@ async def init_db():
|
||||
await conn.execute(text(index_sql))
|
||||
except Exception as e:
|
||||
logger.debug("Index creation skipped for %s: %s", table_name, e)
|
||||
|
||||
logger.info("✅ База данных успешно инициализирована")
|
||||
|
||||
|
||||
logger.info("База данных успешно инициализирована")
|
||||
|
||||
health = await db_manager.health_check()
|
||||
logger.info(f"📊 Database health: {health}")
|
||||
logger.info("Database health: %s", health)
|
||||
|
||||
|
||||
async def close_db():
|
||||
"""Корректное закрытие всех соединений"""
|
||||
logger.info("🔄 Закрытие соединений с БД...")
|
||||
|
||||
logger.info("Закрытие соединений с БД...")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if db_manager.read_replica_engine:
|
||||
await db_manager.read_replica_engine.dispose()
|
||||
|
||||
logger.info("✅ Все подключения к базе данных закрыты")
|
||||
|
||||
logger.info("Все подключения к базе данных закрыты")
|
||||
|
||||
# ============================================================================
|
||||
# CONNECTION POOL METRICS (для мониторинга)
|
||||
|
||||
+116
-4
@@ -86,6 +86,7 @@ class PaymentMethod(Enum):
|
||||
PAL24 = "pal24"
|
||||
WATA = "wata"
|
||||
PLATEGA = "platega"
|
||||
CLOUDPAYMENTS = "cloudpayments"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
@@ -468,6 +469,82 @@ class PlategaPayment(Base):
|
||||
)
|
||||
|
||||
|
||||
class CloudPaymentsPayment(Base):
|
||||
__tablename__ = "cloudpayments_payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
|
||||
# CloudPayments идентификаторы
|
||||
transaction_id_cp = Column(Integer, unique=True, nullable=True, index=True) # TransactionId от CloudPayments
|
||||
invoice_id = Column(String(255), unique=True, nullable=False, index=True) # Наш InvoiceId
|
||||
|
||||
amount_kopeks = Column(Integer, nullable=False)
|
||||
currency = Column(String(10), nullable=False, default="RUB")
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
status = Column(String(50), nullable=False, default="pending") # pending, completed, failed, authorized
|
||||
is_paid = Column(Boolean, default=False)
|
||||
paid_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Данные карты (маскированные)
|
||||
card_first_six = Column(String(6), nullable=True)
|
||||
card_last_four = Column(String(4), nullable=True)
|
||||
card_type = Column(String(50), nullable=True) # Visa, MasterCard, etc.
|
||||
card_exp_date = Column(String(10), nullable=True) # MM/YY
|
||||
|
||||
# Токен для рекуррентных платежей
|
||||
token = Column(String(255), nullable=True)
|
||||
|
||||
# URL для оплаты (виджет)
|
||||
payment_url = Column(Text, nullable=True)
|
||||
|
||||
# Email плательщика
|
||||
email = Column(String(255), nullable=True)
|
||||
|
||||
# Тестовый режим
|
||||
test_mode = Column(Boolean, default=False)
|
||||
|
||||
# Дополнительные данные
|
||||
metadata_json = Column(JSON, nullable=True)
|
||||
callback_payload = Column(JSON, nullable=True)
|
||||
|
||||
# Связь с транзакцией в нашей системе
|
||||
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
user = relationship("User", backref="cloudpayments_payments")
|
||||
transaction = relationship("Transaction", backref="cloudpayments_payment")
|
||||
|
||||
@property
|
||||
def amount_rubles(self) -> float:
|
||||
return self.amount_kopeks / 100
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == "pending"
|
||||
|
||||
@property
|
||||
def is_completed(self) -> bool:
|
||||
return self.status == "completed" and self.is_paid
|
||||
|
||||
@property
|
||||
def is_failed(self) -> bool:
|
||||
return self.status == "failed"
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug helper
|
||||
return (
|
||||
"<CloudPaymentsPayment(id={0}, invoice={1}, amount={2}₽, status={3})>".format(
|
||||
self.id,
|
||||
self.invoice_id,
|
||||
self.amount_rubles,
|
||||
self.status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
@@ -611,6 +688,17 @@ class User(Base):
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
|
||||
poll_responses = relationship("PollResponse", back_populates="user")
|
||||
last_pinned_message_id = Column(Integer, nullable=True)
|
||||
|
||||
# Ограничения пользователя
|
||||
restriction_topup = Column(Boolean, default=False, nullable=False) # Запрет пополнения
|
||||
restriction_subscription = Column(Boolean, default=False, nullable=False) # Запрет продления/покупки
|
||||
restriction_reason = Column(String(500), nullable=True) # Причина ограничения
|
||||
|
||||
@property
|
||||
def has_restrictions(self) -> bool:
|
||||
"""Проверить, есть ли у пользователя активные ограничения."""
|
||||
return self.restriction_topup or self.restriction_subscription
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
@@ -677,11 +765,13 @@ class Subscription(Base):
|
||||
|
||||
traffic_limit_gb = Column(Integer, default=0)
|
||||
traffic_used_gb = Column(Float, default=0.0)
|
||||
purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик (для расчета цены сброса)
|
||||
|
||||
subscription_url = Column(String, nullable=True)
|
||||
subscription_crypto_link = Column(String, nullable=True)
|
||||
|
||||
device_limit = Column(Integer, default=1)
|
||||
modem_enabled = Column(Boolean, default=False)
|
||||
|
||||
connected_squads = Column(JSON, default=list)
|
||||
|
||||
@@ -840,10 +930,14 @@ class Transaction(Base):
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
payment_method = Column(String(50), nullable=True)
|
||||
external_id = Column(String(255), nullable=True)
|
||||
|
||||
external_id = Column(String(255), nullable=True)
|
||||
|
||||
is_completed = Column(Boolean, default=True)
|
||||
|
||||
|
||||
# NaloGO чек
|
||||
receipt_uuid = Column(String(255), nullable=True, index=True)
|
||||
receipt_created_at = Column(DateTime, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -1029,7 +1123,8 @@ class ContestTemplate(Base):
|
||||
name = Column(String(100), nullable=False)
|
||||
slug = Column(String(50), nullable=False, unique=True, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
prize_days = Column(Integer, nullable=False, default=1)
|
||||
prize_type = Column(String(20), nullable=False, default="days")
|
||||
prize_value = Column(String(50), nullable=False, default="1")
|
||||
max_winners = Column(Integer, nullable=False, default=1)
|
||||
attempts_per_user = Column(Integer, nullable=False, default=1)
|
||||
times_per_day = Column(Integer, nullable=False, default=1)
|
||||
@@ -1553,6 +1648,23 @@ class WelcomeText(Base):
|
||||
creator = relationship("User", backref="created_welcome_texts")
|
||||
|
||||
|
||||
class PinnedMessage(Base):
|
||||
__tablename__ = "pinned_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
content = Column(Text, nullable=False, default="")
|
||||
media_type = Column(String(32), nullable=True)
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
send_before_menu = Column(Boolean, nullable=False, server_default="1", default=True)
|
||||
send_on_every_start = Column(Boolean, nullable=False, server_default="1", default=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
creator = relationship("User", backref="pinned_messages")
|
||||
|
||||
|
||||
class AdvertisingCampaign(Base):
|
||||
__tablename__ = "advertising_campaigns"
|
||||
|
||||
|
||||
@@ -3014,6 +3014,157 @@ async def create_welcome_texts_table():
|
||||
logger.error(f"Ошибка создания таблицы welcome_texts: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def create_pinned_messages_table():
|
||||
table_exists = await check_table_exists("pinned_messages")
|
||||
if table_exists:
|
||||
logger.info("Таблица pinned_messages уже существует")
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
create_sql = """
|
||||
CREATE TABLE pinned_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
media_type VARCHAR(32) NULL,
|
||||
media_file_id VARCHAR(255) NULL,
|
||||
send_before_menu BOOLEAN NOT NULL DEFAULT 1,
|
||||
send_on_every_start BOOLEAN NOT NULL DEFAULT 1,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_by INTEGER NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_pinned_messages_active ON pinned_messages(is_active);
|
||||
"""
|
||||
|
||||
elif db_type == "postgresql":
|
||||
create_sql = """
|
||||
CREATE TABLE pinned_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
media_type VARCHAR(32) NULL,
|
||||
media_file_id VARCHAR(255) NULL,
|
||||
send_before_menu BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
send_on_every_start BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_pinned_messages_active ON pinned_messages(is_active);
|
||||
"""
|
||||
|
||||
elif db_type == "mysql":
|
||||
create_sql = """
|
||||
CREATE TABLE pinned_messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
media_type VARCHAR(32) NULL,
|
||||
media_file_id VARCHAR(255) NULL,
|
||||
send_before_menu BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
send_on_every_start BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX ix_pinned_messages_active ON pinned_messages(is_active);
|
||||
"""
|
||||
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для создания таблицы pinned_messages: {db_type}")
|
||||
return False
|
||||
|
||||
await conn.execute(text(create_sql))
|
||||
|
||||
logger.info("✅ Таблица pinned_messages успешно создана")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания таблицы pinned_messages: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_pinned_message_media_columns():
|
||||
table_exists = await check_table_exists("pinned_messages")
|
||||
if not table_exists:
|
||||
logger.warning("⚠️ Таблица pinned_messages отсутствует — пропускаем обновление медиа полей")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not await check_column_exists("pinned_messages", "media_type"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE pinned_messages ADD COLUMN media_type VARCHAR(32)")
|
||||
)
|
||||
|
||||
if not await check_column_exists("pinned_messages", "media_file_id"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE pinned_messages ADD COLUMN media_file_id VARCHAR(255)")
|
||||
)
|
||||
|
||||
if not await check_column_exists("pinned_messages", "send_before_menu"):
|
||||
default_value = "TRUE" if db_type != "sqlite" else "1"
|
||||
await conn.execute(
|
||||
text(
|
||||
f"ALTER TABLE pinned_messages ADD COLUMN send_before_menu BOOLEAN NOT NULL DEFAULT {default_value}"
|
||||
)
|
||||
)
|
||||
|
||||
if not await check_column_exists("pinned_messages", "send_on_every_start"):
|
||||
default_value = "TRUE" if db_type != "sqlite" else "1"
|
||||
await conn.execute(
|
||||
text(
|
||||
f"ALTER TABLE pinned_messages ADD COLUMN send_on_every_start BOOLEAN NOT NULL DEFAULT {default_value}"
|
||||
)
|
||||
)
|
||||
|
||||
await conn.execute(text("UPDATE pinned_messages SET content = '' WHERE content IS NULL"))
|
||||
|
||||
if db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text("ALTER TABLE pinned_messages ALTER COLUMN content SET DEFAULT ''")
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text("ALTER TABLE pinned_messages MODIFY content TEXT NOT NULL DEFAULT ''")
|
||||
)
|
||||
else:
|
||||
logger.info("ℹ️ Пропускаем установку DEFAULT для content в SQLite")
|
||||
|
||||
logger.info("✅ Медиа поля pinned_messages приведены в актуальное состояние")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления медиа полей pinned_messages: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_user_last_pinned_column():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
if not await check_column_exists("users", "last_pinned_message_id"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE users ADD COLUMN last_pinned_message_id INTEGER")
|
||||
)
|
||||
logger.info("✅ Поле last_pinned_message_id у пользователей готово")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления поля last_pinned_message_id: {e}")
|
||||
return False
|
||||
|
||||
async def add_media_fields_to_broadcast_history():
|
||||
logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ МЕДИА В BROADCAST_HISTORY ===")
|
||||
|
||||
@@ -3125,6 +3276,81 @@ async def add_ticket_sla_columns():
|
||||
return False
|
||||
|
||||
|
||||
async def add_user_restriction_columns() -> bool:
|
||||
"""Добавить колонки ограничений пользователей в таблицу users."""
|
||||
try:
|
||||
col_topup = await check_column_exists('users', 'restriction_topup')
|
||||
col_sub = await check_column_exists('users', 'restriction_subscription')
|
||||
col_reason = await check_column_exists('users', 'restriction_reason')
|
||||
|
||||
if col_topup and col_sub and col_reason:
|
||||
logger.info("ℹ️ Колонки ограничений пользователей уже существуют")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not col_topup:
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT 0 NOT NULL"
|
||||
))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
))
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД: {db_type}")
|
||||
return False
|
||||
logger.info("✅ Добавлена колонка users.restriction_topup")
|
||||
|
||||
if not col_sub:
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT 0 NOT NULL"
|
||||
))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
))
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД: {db_type}")
|
||||
return False
|
||||
logger.info("✅ Добавлена колонка users.restriction_subscription")
|
||||
|
||||
if not col_reason:
|
||||
if db_type == 'sqlite':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN restriction_reason VARCHAR(500) NULL"
|
||||
))
|
||||
elif db_type == 'postgresql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN restriction_reason VARCHAR(500) NULL"
|
||||
))
|
||||
elif db_type == 'mysql':
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN restriction_reason VARCHAR(500) NULL"
|
||||
))
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД: {db_type}")
|
||||
return False
|
||||
logger.info("✅ Добавлена колонка users.restriction_reason")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления колонок ограничений пользователей: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_subscription_crypto_link_column() -> bool:
|
||||
column_exists = await check_column_exists('subscriptions', 'subscription_crypto_link')
|
||||
if column_exists:
|
||||
@@ -4404,6 +4630,202 @@ async def add_promocode_promo_group_column() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def migrate_contest_templates_prize_columns() -> bool:
|
||||
"""Миграция contest_templates: prize_days -> prize_type + prize_value."""
|
||||
try:
|
||||
prize_type_exists = await check_column_exists("contest_templates", "prize_type")
|
||||
prize_value_exists = await check_column_exists("contest_templates", "prize_value")
|
||||
|
||||
if prize_type_exists and prize_value_exists:
|
||||
logger.info("Колонки prize_type и prize_value уже существуют в contest_templates")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
# Добавляем prize_type
|
||||
if not prize_type_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
|
||||
))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
|
||||
))
|
||||
else:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
|
||||
))
|
||||
logger.info("✅ Добавлена колонка prize_type в contest_templates")
|
||||
|
||||
# Добавляем prize_value
|
||||
if not prize_value_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
|
||||
))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
|
||||
))
|
||||
else:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
|
||||
))
|
||||
logger.info("✅ Добавлена колонка prize_value в contest_templates")
|
||||
|
||||
# Мигрируем данные из prize_days в prize_value (если prize_days существует)
|
||||
prize_days_exists = await check_column_exists("contest_templates", "prize_days")
|
||||
if prize_days_exists:
|
||||
await conn.execute(text(
|
||||
"UPDATE contest_templates SET prize_value = CAST(prize_days AS VARCHAR) WHERE prize_type = 'days'"
|
||||
))
|
||||
logger.info("✅ Данные из prize_days перенесены в prize_value")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка миграции prize_type/prize_value в contest_templates: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_subscription_modem_enabled_column() -> bool:
|
||||
"""Добавить колонку modem_enabled в subscriptions."""
|
||||
try:
|
||||
column_exists = await check_column_exists("subscriptions", "modem_enabled")
|
||||
if column_exists:
|
||||
logger.info("Колонка modem_enabled уже существует в subscriptions")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT 0"
|
||||
))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT FALSE"
|
||||
))
|
||||
else:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN modem_enabled TINYINT(1) DEFAULT 0"
|
||||
))
|
||||
|
||||
logger.info("✅ Добавлена колонка modem_enabled в subscriptions")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления modem_enabled в subscriptions: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_subscription_purchased_traffic_column() -> bool:
|
||||
"""Добавить колонку purchased_traffic_gb в subscriptions."""
|
||||
try:
|
||||
column_exists = await check_column_exists("subscriptions", "purchased_traffic_gb")
|
||||
if column_exists:
|
||||
logger.info("Колонка purchased_traffic_gb уже существует в subscriptions")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0"
|
||||
))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0"
|
||||
))
|
||||
else:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INT DEFAULT 0"
|
||||
))
|
||||
|
||||
logger.info("✅ Добавлена колонка purchased_traffic_gb в subscriptions")
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления purchased_traffic_gb в subscriptions: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_transaction_receipt_columns() -> bool:
|
||||
"""Добавить колонки receipt_uuid и receipt_created_at в transactions."""
|
||||
try:
|
||||
receipt_uuid_exists = await check_column_exists("transactions", "receipt_uuid")
|
||||
receipt_created_at_exists = await check_column_exists("transactions", "receipt_created_at")
|
||||
|
||||
if receipt_uuid_exists and receipt_created_at_exists:
|
||||
logger.info("Колонки receipt_uuid и receipt_created_at уже существуют в transactions")
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not receipt_uuid_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)"
|
||||
))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)"
|
||||
))
|
||||
else:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)"
|
||||
))
|
||||
logger.info("✅ Добавлена колонка receipt_uuid в transactions")
|
||||
|
||||
if not receipt_created_at_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE transactions ADD COLUMN receipt_created_at DATETIME"
|
||||
))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE transactions ADD COLUMN receipt_created_at TIMESTAMP"
|
||||
))
|
||||
else:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE transactions ADD COLUMN receipt_created_at DATETIME"
|
||||
))
|
||||
logger.info("✅ Добавлена колонка receipt_created_at в transactions")
|
||||
|
||||
# Создаём индекс на receipt_uuid
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
if db_type == "postgresql":
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_transactions_receipt_uuid "
|
||||
"ON transactions (receipt_uuid)"
|
||||
))
|
||||
elif db_type == "sqlite":
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_transactions_receipt_uuid "
|
||||
"ON transactions (receipt_uuid)"
|
||||
))
|
||||
else:
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX ix_transactions_receipt_uuid "
|
||||
"ON transactions (receipt_uuid)"
|
||||
))
|
||||
except Exception as idx_error:
|
||||
logger.warning(f"Индекс на receipt_uuid возможно уже существует: {idx_error}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"❌ Ошибка добавления колонок чеков в transactions: {error}")
|
||||
return False
|
||||
|
||||
|
||||
async def run_universal_migration():
|
||||
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
|
||||
|
||||
@@ -4591,6 +5013,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей contest_templates")
|
||||
|
||||
logger.info("=== МИГРАЦИЯ КОЛОНОК ПРИЗА В CONTEST_TEMPLATES ===")
|
||||
prize_columns_ready = await migrate_contest_templates_prize_columns()
|
||||
if prize_columns_ready:
|
||||
logger.info("✅ Колонки prize_type и prize_value готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с миграцией prize_type/prize_value")
|
||||
|
||||
contest_rounds_ready = await create_contest_rounds_table()
|
||||
if contest_rounds_ready:
|
||||
logger.info("✅ Таблица contest_rounds готова")
|
||||
@@ -4690,12 +5119,33 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей user_messages")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PINNED_MESSAGES ===")
|
||||
pinned_messages_created = await create_pinned_messages_table()
|
||||
if pinned_messages_created:
|
||||
logger.info("✅ Таблица pinned_messages готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей pinned_messages")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ/ОБНОВЛЕНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===")
|
||||
welcome_texts_created = await create_welcome_texts_table()
|
||||
if welcome_texts_created:
|
||||
logger.info("✅ Таблица welcome_texts готова с полем is_enabled")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей welcome_texts")
|
||||
|
||||
logger.info("=== ОБНОВЛЕНИЕ СХЕМЫ PINNED_MESSAGES ===")
|
||||
pinned_media_ready = await ensure_pinned_message_media_columns()
|
||||
if pinned_media_ready:
|
||||
logger.info("✅ Медиа поля для pinned_messages готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с медиа полями pinned_messages")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ СЛЕДА ОТПРАВКИ ЗАКРЕПА ДЛЯ ПОЛЬЗОВАТЕЛЕЙ ===")
|
||||
last_pinned_ready = await ensure_user_last_pinned_column()
|
||||
if last_pinned_ready:
|
||||
logger.info("✅ Колонка last_pinned_message_id добавлена")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось обновить колонку last_pinned_message_id")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ МЕДИА ПОЛЕЙ В BROADCAST_HISTORY ===")
|
||||
media_fields_added = await add_media_fields_to_broadcast_history()
|
||||
@@ -4725,6 +5175,27 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ MODEM_ENABLED ДЛЯ ПОДПИСОК ===")
|
||||
modem_enabled_added = await add_subscription_modem_enabled_column()
|
||||
if modem_enabled_added:
|
||||
logger.info("✅ Колонка modem_enabled готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением колонки modem_enabled")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ PURCHASED_TRAFFIC_GB ДЛЯ ПОДПИСОК ===")
|
||||
purchased_traffic_added = await add_subscription_purchased_traffic_column()
|
||||
if purchased_traffic_added:
|
||||
logger.info("✅ Колонка purchased_traffic_gb готова")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением колонки purchased_traffic_gb")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ОГРАНИЧЕНИЙ ПОЛЬЗОВАТЕЛЕЙ ===")
|
||||
restrictions_added = await add_user_restriction_columns()
|
||||
if restrictions_added:
|
||||
logger.info("✅ Колонки ограничений пользователей готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением колонок ограничений пользователей")
|
||||
|
||||
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -4825,6 +5296,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с таблицей subscription_events")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ЧЕКОВ В TRANSACTIONS ===")
|
||||
receipt_columns_ready = await add_transaction_receipt_columns()
|
||||
if receipt_columns_ready:
|
||||
logger.info("✅ Колонки receipt_uuid и receipt_created_at готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с колонками чеков в transactions")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
|
||||
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
|
||||
@@ -4880,8 +5358,13 @@ async def check_migration_status():
|
||||
"cryptobot_table": False,
|
||||
"heleket_table": False,
|
||||
"user_messages_table": False,
|
||||
"pinned_messages_table": False,
|
||||
"welcome_texts_table": False,
|
||||
"welcome_texts_is_enabled_column": False,
|
||||
"pinned_messages_media_columns": False,
|
||||
"pinned_messages_position_column": False,
|
||||
"pinned_messages_start_mode_column": False,
|
||||
"users_last_pinned_column": False,
|
||||
"broadcast_history_media_fields": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False,
|
||||
@@ -4902,6 +5385,13 @@ async def check_migration_status():
|
||||
"users_promo_offer_discount_expires_column": False,
|
||||
"users_referral_commission_percent_column": False,
|
||||
"subscription_crypto_link_column": False,
|
||||
"subscription_modem_enabled_column": False,
|
||||
"subscription_purchased_traffic_column": False,
|
||||
"users_restriction_topup_column": False,
|
||||
"users_restriction_subscription_column": False,
|
||||
"users_restriction_reason_column": False,
|
||||
"contest_templates_prize_type_column": False,
|
||||
"contest_templates_prize_value_column": False,
|
||||
"discount_offers_table": False,
|
||||
"discount_offers_effect_column": False,
|
||||
"discount_offers_extra_column": False,
|
||||
@@ -4924,6 +5414,7 @@ async def check_migration_status():
|
||||
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
|
||||
status["heleket_table"] = await check_table_exists('heleket_payments')
|
||||
status["user_messages_table"] = await check_table_exists('user_messages')
|
||||
status["pinned_messages_table"] = await check_table_exists('pinned_messages')
|
||||
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
|
||||
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
|
||||
status["public_offers_table"] = await check_table_exists('public_offers')
|
||||
@@ -4961,7 +5452,14 @@ async def check_migration_status():
|
||||
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
|
||||
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
|
||||
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
|
||||
|
||||
status["subscription_modem_enabled_column"] = await check_column_exists('subscriptions', 'modem_enabled')
|
||||
status["subscription_purchased_traffic_column"] = await check_column_exists('subscriptions', 'purchased_traffic_gb')
|
||||
status["users_restriction_topup_column"] = await check_column_exists('users', 'restriction_topup')
|
||||
status["users_restriction_subscription_column"] = await check_column_exists('users', 'restriction_subscription')
|
||||
status["users_restriction_reason_column"] = await check_column_exists('users', 'restriction_reason')
|
||||
status["contest_templates_prize_type_column"] = await check_column_exists('contest_templates', 'prize_type')
|
||||
status["contest_templates_prize_value_column"] = await check_column_exists('contest_templates', 'prize_value')
|
||||
|
||||
media_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'has_media') and
|
||||
await check_column_exists('broadcast_history', 'media_type') and
|
||||
@@ -4969,7 +5467,30 @@ async def check_migration_status():
|
||||
await check_column_exists('broadcast_history', 'media_caption')
|
||||
)
|
||||
status["broadcast_history_media_fields"] = media_fields_exist
|
||||
|
||||
|
||||
pinned_media_columns_exist = (
|
||||
status["pinned_messages_table"]
|
||||
and await check_column_exists('pinned_messages', 'media_type')
|
||||
and await check_column_exists('pinned_messages', 'media_file_id')
|
||||
)
|
||||
status["pinned_messages_media_columns"] = pinned_media_columns_exist
|
||||
|
||||
status["pinned_messages_position_column"] = (
|
||||
status["pinned_messages_table"]
|
||||
and await check_column_exists('pinned_messages', 'send_before_menu')
|
||||
)
|
||||
|
||||
status["pinned_messages_start_mode_column"] = (
|
||||
status["pinned_messages_table"]
|
||||
and await check_column_exists('pinned_messages', 'send_on_every_start')
|
||||
)
|
||||
|
||||
status["users_last_pinned_column"] = await check_column_exists('users', 'last_pinned_message_id')
|
||||
|
||||
# Колонки чеков в transactions
|
||||
status["transactions_receipt_uuid_column"] = await check_column_exists('transactions', 'receipt_uuid')
|
||||
status["transactions_receipt_created_at_column"] = await check_column_exists('transactions', 'receipt_created_at')
|
||||
|
||||
async with engine.begin() as conn:
|
||||
duplicates_check = await conn.execute(text("""
|
||||
SELECT COUNT(*) FROM (
|
||||
@@ -4987,10 +5508,15 @@ async def check_migration_status():
|
||||
"cryptobot_table": "Таблица CryptoBot payments",
|
||||
"heleket_table": "Таблица Heleket payments",
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"pinned_messages_table": "Таблица закреплённых сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"privacy_policies_table": "Таблица политик конфиденциальности",
|
||||
"public_offers_table": "Таблица публичных оферт",
|
||||
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
||||
"pinned_messages_media_columns": "Медиа поля в pinned_messages",
|
||||
"pinned_messages_position_column": "Позиция закрепа (до/после меню)",
|
||||
"pinned_messages_start_mode_column": "Режим отправки закрепа при /start",
|
||||
"users_last_pinned_column": "Колонка last_pinned_message_id у пользователей",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_events_table": "Таблица событий подписок",
|
||||
@@ -5009,6 +5535,10 @@ async def check_migration_status():
|
||||
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
|
||||
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
|
||||
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
|
||||
"subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions",
|
||||
"subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions",
|
||||
"contest_templates_prize_type_column": "Колонка prize_type в contest_templates",
|
||||
"contest_templates_prize_value_column": "Колонка prize_value в contest_templates",
|
||||
"discount_offers_table": "Таблица discount_offers",
|
||||
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
|
||||
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
|
||||
@@ -5024,6 +5554,8 @@ async def check_migration_status():
|
||||
"promo_offer_templates_active_discount_column": "Колонка active_discount_hours в promo_offer_templates",
|
||||
"promo_offer_logs_table": "Таблица promo_offer_logs",
|
||||
"subscription_temporary_access_table": "Таблица subscription_temporary_access",
|
||||
"transactions_receipt_uuid_column": "Колонка receipt_uuid в transactions",
|
||||
"transactions_receipt_created_at_column": "Колонка receipt_created_at в transactions",
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
|
||||
Vendored
+34
-10
@@ -225,14 +225,24 @@ class RemnaWaveAPIError(Exception):
|
||||
|
||||
|
||||
class RemnaWaveAPI:
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None,
|
||||
username: Optional[str] = None, password: Optional[str] = None):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
secret_key: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
caddy_token: Optional[str] = None,
|
||||
auth_type: str = "api_key",
|
||||
):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_key = api_key
|
||||
self.secret_key = secret_key
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.caddy_token = caddy_token
|
||||
self.auth_type = auth_type.lower() if auth_type else "api_key"
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
self.authenticated = False
|
||||
|
||||
@@ -264,19 +274,32 @@ class RemnaWaveAPI:
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
'X-Real-IP': '127.0.0.1'
|
||||
}
|
||||
|
||||
if self.username and self.password:
|
||||
import base64
|
||||
|
||||
# Caddy авторизация — добавляется поверх основной
|
||||
if self.caddy_token:
|
||||
# Caddy Security: готовый base64 токен используется как есть
|
||||
headers['Authorization'] = f'Basic {self.caddy_token}'
|
||||
logger.debug("Используем Caddy Basic Auth")
|
||||
|
||||
# Основная авторизация RemnaWave API
|
||||
if self.auth_type == "basic" and self.username and self.password:
|
||||
credentials = f"{self.username}:{self.password}"
|
||||
encoded_credentials = base64.b64encode(credentials.encode()).decode()
|
||||
headers['X-Api-Key'] = f"Basic {encoded_credentials}"
|
||||
logger.debug("Используем Basic Auth в X-Api-Key заголовке")
|
||||
elif self.auth_type == "caddy":
|
||||
# Для caddy auth_type основная авторизация уже в Authorization header
|
||||
# Но API ключ всё равно нужен для RemnaWave
|
||||
if self.api_key:
|
||||
headers['X-Api-Key'] = self.api_key
|
||||
logger.debug("Используем API ключ для RemnaWave + Caddy авторизацию")
|
||||
else:
|
||||
# api_key или bearer — стандартный режим
|
||||
headers['X-Api-Key'] = self.api_key
|
||||
if not self.caddy_token:
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
logger.debug("Используем API ключ в X-Api-Key заголовке")
|
||||
|
||||
headers['Authorization'] = f'Bearer {self.api_key}'
|
||||
|
||||
|
||||
return headers
|
||||
|
||||
async def __aenter__(self):
|
||||
@@ -416,7 +439,8 @@ class RemnaWaveAPI:
|
||||
data['tag'] = tag
|
||||
if active_internal_squads:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
|
||||
logger.debug("Создание пользователя в панели: %s", data)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
user = self._parse_user(response['response'])
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
@@ -61,7 +61,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
},
|
||||
"payments": {
|
||||
"title": "💳 Платежные системы",
|
||||
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
|
||||
"description": "YooKassa, CryptoBot, Heleket, CloudPayments, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
|
||||
"icon": "💳",
|
||||
"categories": (
|
||||
"PAYMENT",
|
||||
@@ -69,6 +69,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
|
||||
"YOOKASSA",
|
||||
"CRYPTOBOT",
|
||||
"HELEKET",
|
||||
"CLOUDPAYMENTS",
|
||||
"MULENPAY",
|
||||
"PAL24",
|
||||
"WATA",
|
||||
@@ -255,6 +256,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
|
||||
"YooKassa": settings.is_yookassa_enabled(),
|
||||
"CryptoBot": settings.is_cryptobot_enabled(),
|
||||
"Platega": settings.is_platega_enabled(),
|
||||
"CloudPayments": settings.is_cloudpayments_enabled(),
|
||||
"MulenPay": settings.is_mulenpay_enabled(),
|
||||
"PAL24": settings.is_pal24_enabled(),
|
||||
"Tribute": settings.TRIBUTE_ENABLED,
|
||||
|
||||
@@ -688,10 +688,15 @@ async def show_detailed_stats(
|
||||
"📈 <b>Статистика конкурса</b>",
|
||||
f"🏆 {contest.title}",
|
||||
"",
|
||||
f"👥 Участников: <b>{stats['total_participants']}</b>",
|
||||
f"👥 Участников (рефереров): <b>{stats['total_participants']}</b>",
|
||||
f"📨 Приглашено рефералов: <b>{stats['total_invited']}</b>",
|
||||
f"💰 Оплатили подписок: <b>{stats['total_paid_amount'] // 100} руб.</b>",
|
||||
f"❌ Не оплатили: <b>{stats['total_unpaid']}</b>",
|
||||
"",
|
||||
f"💳 Рефералов оплатили: <b>{stats.get('paid_count', 0)}</b>",
|
||||
f"❌ Рефералов не оплатили: <b>{stats.get('unpaid_count', 0)}</b>",
|
||||
"",
|
||||
"<b>💰 СУММЫ:</b>",
|
||||
f" 🛒 Покупки подписок: <b>{stats.get('subscription_total', 0) // 100} руб.</b>",
|
||||
f" 📥 Пополнения баланса: <b>{stats.get('deposit_total', 0) // 100} руб.</b>",
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
@@ -760,6 +765,198 @@ async def show_detailed_stats_page(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def sync_contest(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Синхронизировать события конкурса с реальными платежами."""
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
if not contest:
|
||||
await callback.answer("Конкурс не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer("🔄 Синхронизация запущена...", show_alert=False)
|
||||
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
stats = await referral_contest_service.sync_contest(db, contest_id)
|
||||
|
||||
if "error" in stats:
|
||||
await callback.message.answer(
|
||||
f"❌ Ошибка синхронизации:\n{stats['error']}",
|
||||
)
|
||||
return
|
||||
|
||||
# Формируем сообщение о результатах
|
||||
# Показываем точные даты которые использовались для фильтрации
|
||||
start_str = stats.get('contest_start', contest.start_at.isoformat())
|
||||
end_str = stats.get('contest_end', contest.end_at.isoformat())
|
||||
|
||||
lines = [
|
||||
"✅ <b>Синхронизация завершена!</b>",
|
||||
"",
|
||||
f"📊 <b>Конкурс:</b> {contest.title}",
|
||||
f"📅 <b>Период:</b> {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}",
|
||||
f"🔍 <b>Фильтр транзакций:</b>",
|
||||
f" <code>{start_str}</code>",
|
||||
f" <code>{end_str}</code>",
|
||||
"",
|
||||
f"📝 Рефералов в периоде: <b>{stats.get('total_events', 0)}</b>",
|
||||
f"⚠️ Отфильтровано (вне периода): <b>{stats.get('filtered_out_events', 0)}</b>",
|
||||
f"📊 Всего событий в БД: <b>{stats.get('total_all_events', 0)}</b>",
|
||||
"",
|
||||
f"🔄 Обновлено сумм: <b>{stats.get('updated', 0)}</b>",
|
||||
f"⏭ Без изменений: <b>{stats.get('skipped', 0)}</b>",
|
||||
"",
|
||||
f"💳 Рефералов оплатили: <b>{stats.get('paid_count', 0)}</b>",
|
||||
f"❌ Рефералов не оплатили: <b>{stats.get('unpaid_count', 0)}</b>",
|
||||
"",
|
||||
"<b>💰 СУММЫ:</b>",
|
||||
f" 🛒 Покупки подписок: <b>{stats.get('subscription_total', 0) // 100} руб.</b>",
|
||||
f" 📥 Пополнения баланса: <b>{stats.get('deposit_total', 0) // 100} руб.</b>",
|
||||
]
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
back_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад к конкурсу", callback_data=f"admin_contest_view_{contest_id}")]
|
||||
])
|
||||
|
||||
await callback.message.answer(
|
||||
"\n".join(lines),
|
||||
parse_mode="HTML",
|
||||
reply_markup=back_keyboard,
|
||||
)
|
||||
|
||||
# Обновляем основное сообщение с новой статистикой
|
||||
detailed_stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
|
||||
general_lines = [
|
||||
f"🏆 <b>{contest.title}</b>",
|
||||
f"📅 Период: {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}",
|
||||
"",
|
||||
f"👥 Участников (рефереров): <b>{detailed_stats['total_participants']}</b>",
|
||||
f"📨 Приглашено рефералов: <b>{detailed_stats['total_invited']}</b>",
|
||||
"",
|
||||
f"💳 Рефералов оплатили: <b>{detailed_stats.get('paid_count', 0)}</b>",
|
||||
f"❌ Рефералов не оплатили: <b>{detailed_stats.get('unpaid_count', 0)}</b>",
|
||||
f"🛒 Покупки подписок: <b>{detailed_stats['total_paid_amount'] // 100} руб.</b>",
|
||||
]
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(general_lines),
|
||||
reply_markup=get_referral_contest_manage_keyboard(
|
||||
contest_id, is_active=contest.is_active, language=db_user.language
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def debug_contest_transactions(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Показать транзакции рефералов конкурса для отладки."""
|
||||
if not settings.is_contests_enabled():
|
||||
await callback.answer(
|
||||
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
contest_id = int(callback.data.split("_")[-1])
|
||||
contest = await get_referral_contest(db, contest_id)
|
||||
|
||||
if not contest:
|
||||
await callback.answer("Конкурс не найден.", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer("🔍 Загружаю данные...", show_alert=False)
|
||||
|
||||
from app.database.crud.referral_contest import debug_contest_transactions as debug_txs
|
||||
debug_data = await debug_txs(db, contest_id, limit=10)
|
||||
|
||||
if "error" in debug_data:
|
||||
await callback.message.answer(f"❌ Ошибка: {debug_data['error']}")
|
||||
return
|
||||
|
||||
deposit_total = debug_data.get('deposit_total_kopeks', 0) // 100
|
||||
subscription_total = debug_data.get('subscription_total_kopeks', 0) // 100
|
||||
|
||||
lines = [
|
||||
"🔍 <b>Отладка транзакций конкурса</b>",
|
||||
"",
|
||||
f"📊 <b>Конкурс:</b> {contest.title}",
|
||||
f"📅 <b>Период фильтрации:</b>",
|
||||
f" Начало: <code>{debug_data.get('contest_start')}</code>",
|
||||
f" Конец: <code>{debug_data.get('contest_end')}</code>",
|
||||
f"👥 <b>Рефералов в периоде:</b> {debug_data.get('referral_count', 0)}",
|
||||
f"⚠️ <b>Отфильтровано (вне периода):</b> {debug_data.get('filtered_out', 0)}",
|
||||
f"📊 <b>Всего событий в БД:</b> {debug_data.get('total_all_events', 0)}",
|
||||
"",
|
||||
"<b>💰 СУММЫ:</b>",
|
||||
f" 📥 Пополнения баланса: <b>{deposit_total}</b> руб.",
|
||||
f" 🛒 Покупки подписок: <b>{subscription_total}</b> руб.",
|
||||
"",
|
||||
]
|
||||
|
||||
# Показываем транзакции В периоде
|
||||
txs_in = debug_data.get('transactions_in_period', [])
|
||||
if txs_in:
|
||||
lines.append(f"✅ <b>Транзакции в периоде</b> (первые {len(txs_in)}):")
|
||||
for tx in txs_in[:5]: # Показываем максимум 5
|
||||
lines.append(
|
||||
f" • {tx['created_at'][:10]} | "
|
||||
f"{tx['type']} | "
|
||||
f"{tx['amount_kopeks'] // 100}₽ | "
|
||||
f"user={tx['user_id']}"
|
||||
)
|
||||
if len(txs_in) > 5:
|
||||
lines.append(f" ... и ещё {len(txs_in) - 5}")
|
||||
else:
|
||||
lines.append("✅ <b>Транзакций в периоде:</b> 0")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Показываем транзакции ВНЕ периода
|
||||
txs_out = debug_data.get('transactions_outside_period', [])
|
||||
if txs_out:
|
||||
lines.append(f"❌ <b>Транзакции вне периода</b> (первые {len(txs_out)}):")
|
||||
for tx in txs_out[:5]:
|
||||
lines.append(
|
||||
f" • {tx['created_at'][:10]} | "
|
||||
f"{tx['type']} | "
|
||||
f"{tx['amount_kopeks'] // 100}₽ | "
|
||||
f"user={tx['user_id']}"
|
||||
)
|
||||
if len(txs_out) > 5:
|
||||
lines.append(f" ... и ещё {len(txs_out) - 5}")
|
||||
else:
|
||||
lines.append("❌ <b>Транзакций вне периода:</b> 0")
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
back_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад к конкурсу", callback_data=f"admin_contest_view_{contest_id}")]
|
||||
])
|
||||
|
||||
await callback.message.answer(
|
||||
"\n".join(lines),
|
||||
parse_mode="HTML",
|
||||
reply_markup=back_keyboard,
|
||||
)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_contests_menu, F.data == "admin_contests")
|
||||
dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral")
|
||||
@@ -772,6 +969,8 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_"))
|
||||
dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_"))
|
||||
dp.callback_query.register(show_detailed_stats_page, F.data.startswith("admin_contest_detailed_stats_page_"))
|
||||
dp.callback_query.register(sync_contest, F.data.startswith("admin_contest_sync_"))
|
||||
dp.callback_query.register(debug_contest_transactions, F.data.startswith("admin_contest_debug_"))
|
||||
dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create")
|
||||
dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"]))
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
clear_attempts,
|
||||
create_round,
|
||||
get_template_by_id,
|
||||
list_templates,
|
||||
update_template_fields,
|
||||
create_round,
|
||||
)
|
||||
from app.database.models import ContestTemplate
|
||||
from app.keyboards.admin import (
|
||||
@@ -28,7 +29,8 @@ from app.utils.decorators import admin_required, error_handler
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EDITABLE_FIELDS: Dict[str, Dict] = {
|
||||
"prize_days": {"type": int, "min": 1, "label": "приз (дни)"},
|
||||
"prize_type": {"type": str, "label": "тип приза (days/balance/custom)"},
|
||||
"prize_value": {"type": str, "label": "значение приза"},
|
||||
"max_winners": {"type": int, "min": 1, "label": "макс. победителей"},
|
||||
"attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"},
|
||||
"times_per_day": {"type": int, "min": 1, "label": "раундов в день"},
|
||||
@@ -57,7 +59,8 @@ async def show_daily_contests(
|
||||
else:
|
||||
for tpl in templates:
|
||||
status = "🟢" if tpl.is_enabled else "⚪️"
|
||||
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {tpl.max_winners}")
|
||||
prize_info = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
|
||||
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {prize_info}, макс {tpl.max_winners}")
|
||||
|
||||
keyboard_rows = []
|
||||
if templates:
|
||||
@@ -101,10 +104,12 @@ async def show_daily_contest(
|
||||
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
|
||||
return
|
||||
|
||||
prize_display = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
|
||||
lines = [
|
||||
f"🏷 <b>{tpl.name}</b> (slug: {tpl.slug})",
|
||||
f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}",
|
||||
f"Приз: {tpl.prize_days} дн. | Макс победителей: {tpl.max_winners}",
|
||||
f"Тип приза: {tpl.prize_type or 'days'} | Значение: {tpl.prize_value or '1'}",
|
||||
f"Макс победителей: {tpl.max_winners}",
|
||||
f"Попыток/польз: {tpl.attempts_per_user}",
|
||||
f"Раундов в день: {tpl.times_per_day}",
|
||||
f"Расписание: {tpl.schedule_times or '-'}",
|
||||
|
||||
+381
-20
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
@@ -25,13 +26,19 @@ from app.keyboards.admin import (
|
||||
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
|
||||
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
|
||||
BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
|
||||
get_broadcast_button_config, get_broadcast_button_labels
|
||||
get_broadcast_button_config, get_broadcast_button_labels, get_pinned_message_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.user import get_users_list
|
||||
from app.database.crud.subscription import get_expiring_subscriptions
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.services.pinned_message_service import (
|
||||
broadcast_pinned_message,
|
||||
get_active_pinned_message,
|
||||
set_active_pinned_message,
|
||||
unpin_active_pinned_message,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -167,6 +174,302 @@ async def show_messages_menu(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_pinned_message_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await state.clear()
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
|
||||
if pinned_message:
|
||||
content_preview = html.escape(pinned_message.content or "")
|
||||
last_updated = pinned_message.updated_at or pinned_message.created_at
|
||||
timestamp_text = last_updated.strftime("%d.%m.%Y %H:%M") if last_updated else "—"
|
||||
media_line = ""
|
||||
if pinned_message.media_type:
|
||||
media_label = "Фото" if pinned_message.media_type == "photo" else "Видео"
|
||||
media_line = f"📎 Медиа: {media_label}\n"
|
||||
position_line = (
|
||||
"⬆️ Отправлять перед меню"
|
||||
if pinned_message.send_before_menu
|
||||
else "⬇️ Отправлять после меню"
|
||||
)
|
||||
start_mode_line = (
|
||||
"🔁 При каждом /start"
|
||||
if pinned_message.send_on_every_start
|
||||
else "🚫 Только один раз и при обновлении"
|
||||
)
|
||||
body = (
|
||||
"📌 <b>Закрепленное сообщение</b>\n\n"
|
||||
"📝 Текущий текст:\n"
|
||||
f"<code>{content_preview}</code>\n\n"
|
||||
f"{media_line}"
|
||||
f"{position_line}\n"
|
||||
f"{start_mode_line}\n"
|
||||
f"🕒 Обновлено: {timestamp_text}"
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
"📌 <b>Закрепленное сообщение</b>\n\n"
|
||||
"Сообщение не задано. Отправьте новый текст, чтобы разослать и закрепить его у пользователей."
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
body,
|
||||
reply_markup=get_pinned_message_keyboard(
|
||||
db_user.language,
|
||||
send_before_menu=getattr(pinned_message, "send_before_menu", True),
|
||||
send_on_every_start=getattr(pinned_message, "send_on_every_start", True),
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def prompt_pinned_message_update(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
await state.set_state(AdminStates.editing_pinned_message)
|
||||
await callback.message.edit_text(
|
||||
"✏️ <b>Новое закрепленное сообщение</b>\n\n"
|
||||
"Пришлите текст, фото или видео, которое нужно закрепить.\n"
|
||||
"Бот отправит его всем активным пользователям, открепит старое и закрепит новое без уведомлений.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_pinned_message")]
|
||||
]),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_pinned_message_position(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
if not pinned_message:
|
||||
await callback.answer("Сначала задайте закрепленное сообщение", show_alert=True)
|
||||
return
|
||||
|
||||
pinned_message.send_before_menu = not pinned_message.send_before_menu
|
||||
pinned_message.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
await show_pinned_message_menu(callback, db_user, db, state)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_pinned_message_start_mode(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
if not pinned_message:
|
||||
await callback.answer("Сначала задайте закрепленное сообщение", show_alert=True)
|
||||
return
|
||||
|
||||
pinned_message.send_on_every_start = not pinned_message.send_on_every_start
|
||||
pinned_message.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
await show_pinned_message_menu(callback, db_user, db, state)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_pinned_message(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
if not pinned_message:
|
||||
await callback.answer("Закрепленное сообщение уже отсутствует", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🗑️ <b>Удаление закрепленного сообщения</b>\n\n"
|
||||
"Подождите, пока бот открепит сообщение у пользователей...",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
unpinned_count, failed_count, deleted = await unpin_active_pinned_message(
|
||||
callback.bot,
|
||||
db,
|
||||
)
|
||||
|
||||
if not deleted:
|
||||
await callback.message.edit_text(
|
||||
"❌ Не удалось найти активное закрепленное сообщение для удаления",
|
||||
reply_markup=get_admin_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
total = unpinned_count + failed_count
|
||||
await callback.message.edit_text(
|
||||
"✅ <b>Закрепленное сообщение удалено</b>\n\n"
|
||||
f"👥 Чатов обработано: {total}\n"
|
||||
f"✅ Откреплено: {unpinned_count}\n"
|
||||
f"⚠️ Ошибок: {failed_count}\n\n"
|
||||
"Новое сообщение можно задать кнопкой \"Обновить\".",
|
||||
reply_markup=get_admin_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_pinned_message_update(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
media_type: Optional[str] = None
|
||||
media_file_id: Optional[str] = None
|
||||
|
||||
if message.photo:
|
||||
media_type = "photo"
|
||||
media_file_id = message.photo[-1].file_id
|
||||
elif message.video:
|
||||
media_type = "video"
|
||||
media_file_id = message.video.file_id
|
||||
|
||||
pinned_text = message.html_text or message.caption_html or message.text or message.caption or ""
|
||||
|
||||
if not pinned_text and not media_file_id:
|
||||
await message.answer(
|
||||
texts.t("ADMIN_PINNED_NO_CONTENT", "❌ Не удалось прочитать текст или медиа в сообщении, попробуйте снова.")
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
pinned_message = await set_active_pinned_message(
|
||||
db,
|
||||
pinned_text,
|
||||
db_user.id,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
)
|
||||
except ValueError as validation_error:
|
||||
await message.answer(f"❌ {validation_error}")
|
||||
return
|
||||
|
||||
# Сообщение сохранено, спрашиваем о рассылке
|
||||
from app.keyboards.admin import get_pinned_broadcast_confirm_keyboard
|
||||
from app.states import AdminStates
|
||||
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"ADMIN_PINNED_SAVED_ASK_BROADCAST",
|
||||
"📌 <b>Сообщение сохранено!</b>\n\n"
|
||||
"Выберите, как доставить сообщение пользователям:\n\n"
|
||||
"• <b>Разослать сейчас</b> — отправит и закрепит у всех активных пользователей\n"
|
||||
"• <b>Только при /start</b> — пользователи увидят при следующем запуске бота",
|
||||
),
|
||||
reply_markup=get_pinned_broadcast_confirm_keyboard(db_user.language, pinned_message.id),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_pinned_broadcast)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_pinned_broadcast_now(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Разослать закреплённое сообщение сейчас всем пользователям."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Получаем ID сообщения из callback_data
|
||||
pinned_message_id = int(callback.data.split(":")[1])
|
||||
|
||||
# Получаем сообщение из БД
|
||||
from sqlalchemy import select
|
||||
from app.database.models import PinnedMessage
|
||||
|
||||
result = await db.execute(
|
||||
select(PinnedMessage).where(PinnedMessage.id == pinned_message_id)
|
||||
)
|
||||
pinned_message = result.scalar_one_or_none()
|
||||
|
||||
if not pinned_message:
|
||||
await callback.answer("❌ Сообщение не найдено", show_alert=True)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_PINNED_SAVING", "📌 Сообщение сохранено. Начинаю отправку и закрепление у пользователей..."),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
sent_count, failed_count = await broadcast_pinned_message(
|
||||
callback.bot,
|
||||
db,
|
||||
pinned_message,
|
||||
)
|
||||
|
||||
total = sent_count + failed_count
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_PINNED_UPDATED",
|
||||
"✅ <b>Закрепленное сообщение обновлено</b>\n\n"
|
||||
"👥 Получателей: {total}\n"
|
||||
"✅ Отправлено: {sent}\n"
|
||||
"⚠️ Ошибок: {failed}",
|
||||
).format(total=total, sent=sent_count, failed=failed_count),
|
||||
reply_markup=get_admin_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_pinned_broadcast_skip(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Пропустить рассылку — пользователи увидят при /start."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_PINNED_SAVED_NO_BROADCAST",
|
||||
"✅ <b>Закрепленное сообщение сохранено</b>\n\n"
|
||||
"Рассылка не выполнена. Пользователи увидят сообщение при следующем вводе /start.",
|
||||
),
|
||||
reply_markup=get_admin_messages_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_broadcast_targets(
|
||||
@@ -408,29 +711,56 @@ async def handle_media_selection(
|
||||
await state.update_data(has_media=False)
|
||||
await show_button_selector_callback(callback, db_user, state)
|
||||
return
|
||||
|
||||
|
||||
media_type = callback.data.replace('add_media_', '')
|
||||
|
||||
|
||||
media_instructions = {
|
||||
"photo": "📷 Отправьте фотографию для рассылки:",
|
||||
"video": "🎥 Отправьте видео для рассылки:",
|
||||
"document": "📄 Отправьте документ для рассылки:"
|
||||
}
|
||||
|
||||
|
||||
await state.update_data(
|
||||
media_type=media_type,
|
||||
waiting_for_media=True
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
|
||||
instruction_text = (
|
||||
f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n"
|
||||
f"<i>Размер файла не должен превышать 50 МБ</i>",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
f"<i>Размер файла не должен превышать 50 МБ</i>"
|
||||
)
|
||||
|
||||
instruction_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
|
||||
])
|
||||
|
||||
# Проверяем, является ли текущее сообщение медиа-сообщением
|
||||
is_media_message = (
|
||||
callback.message.photo
|
||||
or callback.message.video
|
||||
or callback.message.document
|
||||
or callback.message.animation
|
||||
or callback.message.audio
|
||||
or callback.message.voice
|
||||
)
|
||||
|
||||
if is_media_message:
|
||||
# Удаляем медиа-сообщение и отправляем новое текстовое
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
instruction_text,
|
||||
reply_markup=instruction_keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
instruction_text,
|
||||
reply_markup=instruction_keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_media)
|
||||
await callback.answer()
|
||||
|
||||
@@ -557,12 +887,12 @@ async def show_button_selector_callback(
|
||||
if selected_buttons is None:
|
||||
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
|
||||
await state.update_data(selected_buttons=selected_buttons)
|
||||
|
||||
|
||||
media_info = ""
|
||||
if has_media:
|
||||
media_type = data.get('media_type', 'файл')
|
||||
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type} добавлен"
|
||||
|
||||
|
||||
text = f"""
|
||||
📘 <b>Выбор дополнительных кнопок</b>
|
||||
|
||||
@@ -579,16 +909,39 @@ async def show_button_selector_callback(
|
||||
|
||||
Выберите нужные кнопки и нажмите "Продолжить":
|
||||
"""
|
||||
|
||||
|
||||
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
|
||||
selected_buttons, has_media, db_user.language
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
|
||||
# Проверяем, является ли текущее сообщение медиа-сообщением
|
||||
# (фото, видео, документ и т.д.) - для них нельзя использовать edit_text
|
||||
is_media_message = (
|
||||
callback.message.photo
|
||||
or callback.message.video
|
||||
or callback.message.document
|
||||
or callback.message.animation
|
||||
or callback.message.audio
|
||||
or callback.message.voice
|
||||
)
|
||||
|
||||
if is_media_message:
|
||||
# Удаляем медиа-сообщение и отправляем новое текстовое
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass # Игнорируем ошибки удаления
|
||||
await callback.message.answer(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -1295,6 +1648,13 @@ def get_target_display_name(target: str) -> str:
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_messages_menu, F.data == "admin_messages")
|
||||
dp.callback_query.register(show_pinned_message_menu, F.data == "admin_pinned_message")
|
||||
dp.callback_query.register(toggle_pinned_message_position, F.data == "admin_pinned_message_position")
|
||||
dp.callback_query.register(toggle_pinned_message_start_mode, F.data == "admin_pinned_message_start_mode")
|
||||
dp.callback_query.register(delete_pinned_message, F.data == "admin_pinned_message_delete")
|
||||
dp.callback_query.register(prompt_pinned_message_update, F.data == "admin_pinned_message_edit")
|
||||
dp.callback_query.register(handle_pinned_broadcast_now, F.data.startswith("admin_pinned_broadcast_now:"))
|
||||
dp.callback_query.register(handle_pinned_broadcast_skip, F.data.startswith("admin_pinned_broadcast_skip:"))
|
||||
dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"]))
|
||||
dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_"))
|
||||
dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast")
|
||||
@@ -1312,3 +1672,4 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(handle_change_media, F.data == "change_media")
|
||||
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
|
||||
dp.message.register(process_broadcast_media, AdminStates.waiting_for_broadcast_media)
|
||||
dp.message.register(process_pinned_message_update, AdminStates.editing_pinned_message)
|
||||
|
||||
@@ -10,6 +10,7 @@ from aiogram.exceptions import TelegramBadRequest
|
||||
from app.config import settings
|
||||
from app.database.database import get_db
|
||||
from app.services.monitoring_service import monitoring_service
|
||||
from app.services.nalogo_queue_service import nalogo_queue_service
|
||||
from app.utils.decorators import admin_required
|
||||
from app.utils.pagination import paginate_list
|
||||
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
|
||||
@@ -911,11 +912,42 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
|
||||
• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
|
||||
"""
|
||||
|
||||
|
||||
# Добавляем информацию о чеках NaloGO
|
||||
if settings.is_nalogo_enabled():
|
||||
nalogo_status = await nalogo_queue_service.get_status()
|
||||
queue_len = nalogo_status.get("queue_length", 0)
|
||||
total_amount = nalogo_status.get("total_amount", 0)
|
||||
running = nalogo_status.get("running", False)
|
||||
|
||||
nalogo_section = f"""
|
||||
🧾 <b>Чеки NaloGO:</b>
|
||||
• Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
|
||||
• В очереди: {queue_len} чек(ов)"""
|
||||
if queue_len > 0:
|
||||
nalogo_section += f"\n• На сумму: {total_amount:,.2f} ₽"
|
||||
text += nalogo_section
|
||||
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
|
||||
])
|
||||
|
||||
buttons = []
|
||||
# Кнопки для работы с чеками NaloGO
|
||||
if settings.is_nalogo_enabled():
|
||||
nalogo_status = await nalogo_queue_service.get_status()
|
||||
nalogo_buttons = []
|
||||
if nalogo_status.get("queue_length", 0) > 0:
|
||||
nalogo_buttons.append(InlineKeyboardButton(
|
||||
text=f"🧾 Отправить ({nalogo_status['queue_length']})",
|
||||
callback_data="admin_mon_nalogo_force_process"
|
||||
))
|
||||
nalogo_buttons.append(InlineKeyboardButton(
|
||||
text="📊 Сверка чеков",
|
||||
callback_data="admin_mon_receipts_missing"
|
||||
))
|
||||
buttons.append(nalogo_buttons)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
@@ -925,6 +957,469 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
|
||||
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_nalogo_force_process")
|
||||
@admin_required
|
||||
async def nalogo_force_process_callback(callback: CallbackQuery):
|
||||
"""Принудительная отправка чеков из очереди."""
|
||||
try:
|
||||
await callback.answer("🔄 Запускаю обработку очереди чеков...", show_alert=False)
|
||||
|
||||
result = await nalogo_queue_service.force_process()
|
||||
|
||||
if "error" in result:
|
||||
await callback.answer(f"❌ {result['error']}", show_alert=True)
|
||||
return
|
||||
|
||||
message = result.get("message", "Готово")
|
||||
processed = result.get("processed", 0)
|
||||
remaining = result.get("remaining", 0)
|
||||
|
||||
if processed > 0:
|
||||
text = f"✅ Обработано: {processed} чек(ов)"
|
||||
if remaining > 0:
|
||||
text += f"\n⏳ Осталось в очереди: {remaining}"
|
||||
else:
|
||||
if remaining > 0:
|
||||
text = f"⚠️ Сервис nalog.ru недоступен\n⏳ В очереди: {remaining} чек(ов)"
|
||||
else:
|
||||
text = "📭 Очередь пуста"
|
||||
|
||||
await callback.answer(text, show_alert=True)
|
||||
|
||||
# Обновляем страницу статистики
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
# Перезагружаем статистику
|
||||
async for db in get_db():
|
||||
from app.database.crud.subscription import get_subscriptions_statistics
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
mon_status = await monitoring_service.get_monitoring_status(db)
|
||||
|
||||
week_ago = datetime.now() - timedelta(days=7)
|
||||
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
|
||||
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
|
||||
week_success = sum(1 for log in week_logs if log['is_success'])
|
||||
week_errors = len(week_logs) - week_success
|
||||
|
||||
stats_text = f"""
|
||||
📊 <b>Статистика мониторинга</b>
|
||||
|
||||
📱 <b>Подписки:</b>
|
||||
• Всего: {sub_stats['total_subscriptions']}
|
||||
• Активных: {sub_stats['active_subscriptions']}
|
||||
• Тестовых: {sub_stats['trial_subscriptions']}
|
||||
• Платных: {sub_stats['paid_subscriptions']}
|
||||
|
||||
📈 <b>За сегодня:</b>
|
||||
• Успешных операций: {mon_status['stats_24h']['successful']}
|
||||
• Ошибок: {mon_status['stats_24h']['failed']}
|
||||
• Успешность: {mon_status['stats_24h']['success_rate']}%
|
||||
|
||||
📊 <b>За неделю:</b>
|
||||
• Всего событий: {len(week_logs)}
|
||||
• Успешных: {week_success}
|
||||
• Ошибок: {week_errors}
|
||||
• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
|
||||
|
||||
🔧 <b>Система:</b>
|
||||
• Интервал: {settings.MONITORING_INTERVAL} мин
|
||||
• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
|
||||
• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
|
||||
"""
|
||||
|
||||
if settings.is_nalogo_enabled():
|
||||
nalogo_status = await nalogo_queue_service.get_status()
|
||||
queue_len = nalogo_status.get("queue_length", 0)
|
||||
total_amount = nalogo_status.get("total_amount", 0)
|
||||
running = nalogo_status.get("running", False)
|
||||
|
||||
nalogo_section = f"""
|
||||
🧾 <b>Чеки NaloGO:</b>
|
||||
• Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
|
||||
• В очереди: {queue_len} чек(ов)"""
|
||||
if queue_len > 0:
|
||||
nalogo_section += f"\n• На сумму: {total_amount:,.2f} ₽"
|
||||
stats_text += nalogo_section
|
||||
|
||||
buttons = []
|
||||
# Кнопки для работы с чеками NaloGO
|
||||
if settings.is_nalogo_enabled():
|
||||
nalogo_status = await nalogo_queue_service.get_status()
|
||||
nalogo_buttons = []
|
||||
if nalogo_status.get("queue_length", 0) > 0:
|
||||
nalogo_buttons.append(InlineKeyboardButton(
|
||||
text=f"🧾 Отправить ({nalogo_status['queue_length']})",
|
||||
callback_data="admin_mon_nalogo_force_process"
|
||||
))
|
||||
nalogo_buttons.append(InlineKeyboardButton(
|
||||
text="📊 Сверка чеков",
|
||||
callback_data="admin_mon_receipts_missing"
|
||||
))
|
||||
buttons.append(nalogo_buttons)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
await callback.message.edit_text(stats_text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка принудительной обработки чеков: {e}")
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_receipts_missing")
|
||||
@admin_required
|
||||
async def receipts_missing_callback(callback: CallbackQuery):
|
||||
"""Сверка чеков по логам."""
|
||||
# Напрямую вызываем сверку по логам
|
||||
await _do_reconcile_logs(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_receipts_link_old")
|
||||
@admin_required
|
||||
async def receipts_link_old_callback(callback: CallbackQuery):
|
||||
"""Привязать старые чеки из NaloGO к транзакциям по сумме и дате."""
|
||||
try:
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy import select, and_
|
||||
from datetime import date, timedelta
|
||||
from app.database.models import Transaction, PaymentMethod, TransactionType
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
|
||||
await callback.answer("🔄 Загружаю чеки из NaloGO...", show_alert=False)
|
||||
|
||||
TRACKING_START_DATE = datetime(2024, 12, 29, 0, 0, 0)
|
||||
|
||||
async for db in get_db():
|
||||
# Получаем старые транзакции без чеков
|
||||
query = select(Transaction).where(
|
||||
and_(
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.payment_method == PaymentMethod.YOOKASSA.value,
|
||||
Transaction.receipt_uuid.is_(None),
|
||||
Transaction.is_completed == True,
|
||||
Transaction.created_at < TRACKING_START_DATE,
|
||||
)
|
||||
).order_by(Transaction.created_at.desc())
|
||||
|
||||
result = await db.execute(query)
|
||||
transactions = result.scalars().all()
|
||||
|
||||
if not transactions:
|
||||
await callback.answer("✅ Нет старых транзакций для привязки", show_alert=True)
|
||||
return
|
||||
|
||||
# Получаем чеки из NaloGO за последние 60 дней
|
||||
nalogo_service = NaloGoService()
|
||||
to_date = date.today()
|
||||
from_date = to_date - timedelta(days=60)
|
||||
|
||||
incomes = await nalogo_service.get_incomes(
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
limit=500,
|
||||
)
|
||||
|
||||
if not incomes:
|
||||
await callback.answer("❌ Не удалось получить чеки из NaloGO", show_alert=True)
|
||||
return
|
||||
|
||||
# Создаём словарь чеков по сумме для быстрого поиска
|
||||
# Ключ: сумма в копейках, значение: список чеков
|
||||
incomes_by_amount = {}
|
||||
for income in incomes:
|
||||
amount = float(income.get("totalAmount", income.get("amount", 0)))
|
||||
amount_kopeks = int(amount * 100)
|
||||
if amount_kopeks not in incomes_by_amount:
|
||||
incomes_by_amount[amount_kopeks] = []
|
||||
incomes_by_amount[amount_kopeks].append(income)
|
||||
|
||||
linked = 0
|
||||
for t in transactions:
|
||||
if t.amount_kopeks in incomes_by_amount:
|
||||
matching_incomes = incomes_by_amount[t.amount_kopeks]
|
||||
if matching_incomes:
|
||||
# Берём первый подходящий чек
|
||||
income = matching_incomes.pop(0)
|
||||
receipt_uuid = income.get("approvedReceiptUuid", income.get("receiptUuid"))
|
||||
if receipt_uuid:
|
||||
t.receipt_uuid = receipt_uuid
|
||||
# Парсим дату чека
|
||||
operation_time = income.get("operationTime")
|
||||
if operation_time:
|
||||
try:
|
||||
from dateutil.parser import isoparse
|
||||
t.receipt_created_at = isoparse(operation_time)
|
||||
except Exception:
|
||||
t.receipt_created_at = datetime.utcnow()
|
||||
linked += 1
|
||||
|
||||
if linked > 0:
|
||||
await db.commit()
|
||||
|
||||
text = f"🔗 <b>Привязка завершена</b>\n\n"
|
||||
text += f"Всего транзакций: {len(transactions)}\n"
|
||||
text += f"Чеков в NaloGO: {len(incomes)}\n"
|
||||
text += f"Привязано: <b>{linked}</b>\n"
|
||||
text += f"Не удалось привязать: {len(transactions) - linked}"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")],
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка привязки старых чеков: {e}", exc_info=True)
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_receipts_reconcile")
|
||||
@admin_required
|
||||
async def receipts_reconcile_menu_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Меню выбора периода сверки."""
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
# Очищаем состояние на случай если остался ввод даты
|
||||
await state.clear()
|
||||
|
||||
# Сразу показываем сверку по логам
|
||||
await _do_reconcile_logs(callback)
|
||||
|
||||
|
||||
async def _do_reconcile_logs(callback: CallbackQuery):
|
||||
"""Внутренняя функция сверки по логам."""
|
||||
try:
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from pathlib import Path
|
||||
import re
|
||||
from collections import defaultdict
|
||||
|
||||
await callback.answer("🔄 Анализирую логи платежей...", show_alert=False)
|
||||
|
||||
# Путь к файлу логов платежей (logs/current/)
|
||||
log_file_path = Path(settings.LOG_FILE).resolve()
|
||||
log_dir = log_file_path.parent
|
||||
current_dir = log_dir / "current"
|
||||
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
|
||||
|
||||
if not payments_log.exists():
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
"❌ <b>Файл логов не найден</b>\n\n"
|
||||
f"Путь: <code>{payments_log}</code>\n\n"
|
||||
"<i>Логи появятся после первого успешного платежа.</i>",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_reconcile_logs")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]
|
||||
])
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
pass # Сообщение не изменилось
|
||||
return
|
||||
|
||||
# Паттерны для парсинга логов
|
||||
# Успешный платёж: "Успешно обработан платеж YooKassa 30e3c6fc-000f-5001-9000-1a9c8b242396: пользователь 1046 пополнил баланс на 200.0₽"
|
||||
payment_pattern = re.compile(
|
||||
r"(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Успешно обработан платеж YooKassa ([a-f0-9-]+).*на ([\d.]+)₽"
|
||||
)
|
||||
# Чек создан: "Чек NaloGO создан для платежа 30e3c6fc-000f-5001-9000-1a9c8b242396: 243udsqtik"
|
||||
receipt_pattern = re.compile(
|
||||
r"(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Чек NaloGO создан для платежа ([a-f0-9-]+): (\w+)"
|
||||
)
|
||||
|
||||
# Читаем и парсим логи
|
||||
payments = {} # payment_id -> {date, amount}
|
||||
receipts = {} # payment_id -> {date, receipt_uuid}
|
||||
|
||||
try:
|
||||
with open(payments_log, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
# Проверяем платежи
|
||||
match = payment_pattern.search(line)
|
||||
if match:
|
||||
date_str, payment_id, amount = match.groups()
|
||||
payments[payment_id] = {
|
||||
"date": date_str,
|
||||
"amount": float(amount)
|
||||
}
|
||||
continue
|
||||
|
||||
# Проверяем чеки
|
||||
match = receipt_pattern.search(line)
|
||||
if match:
|
||||
date_str, payment_id, receipt_uuid = match.groups()
|
||||
receipts[payment_id] = {
|
||||
"date": date_str,
|
||||
"receipt_uuid": receipt_uuid
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка чтения логов: {e}")
|
||||
await callback.message.edit_text(
|
||||
f"❌ <b>Ошибка чтения логов</b>\n\n{str(e)}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
# Находим платежи без чеков
|
||||
payments_without_receipts = []
|
||||
for payment_id, payment_data in payments.items():
|
||||
if payment_id not in receipts:
|
||||
payments_without_receipts.append({
|
||||
"payment_id": payment_id,
|
||||
"date": payment_data["date"],
|
||||
"amount": payment_data["amount"]
|
||||
})
|
||||
|
||||
# Группируем по датам
|
||||
by_date = defaultdict(list)
|
||||
for p in payments_without_receipts:
|
||||
by_date[p["date"]].append(p)
|
||||
|
||||
# Формируем отчёт
|
||||
total_payments = len(payments)
|
||||
total_receipts = len(receipts)
|
||||
missing_count = len(payments_without_receipts)
|
||||
missing_amount = sum(p["amount"] for p in payments_without_receipts)
|
||||
|
||||
text = "📋 <b>Сверка по логам</b>\n\n"
|
||||
text += f"📦 <b>Всего платежей:</b> {total_payments}\n"
|
||||
text += f"🧾 <b>Чеков создано:</b> {total_receipts}\n\n"
|
||||
|
||||
if missing_count == 0:
|
||||
text += "✅ <b>Все платежи имеют чеки!</b>"
|
||||
else:
|
||||
text += f"⚠️ <b>Без чеков:</b> {missing_count} платежей на {missing_amount:,.2f} ₽\n\n"
|
||||
|
||||
# Показываем по датам (последние)
|
||||
sorted_dates = sorted(by_date.keys(), reverse=True)
|
||||
for date_str in sorted_dates[:7]:
|
||||
date_payments = by_date[date_str]
|
||||
date_amount = sum(p["amount"] for p in date_payments)
|
||||
text += f"• <b>{date_str}:</b> {len(date_payments)} шт. на {date_amount:,.2f} ₽\n"
|
||||
|
||||
if len(sorted_dates) > 7:
|
||||
text += f"\n<i>...и ещё {len(sorted_dates) - 7} дней</i>"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_reconcile_logs")],
|
||||
[InlineKeyboardButton(text="📄 Детали", callback_data="admin_mon_reconcile_logs_details")],
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")],
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
except TelegramBadRequest:
|
||||
pass # Сообщение не изменилось
|
||||
|
||||
except TelegramBadRequest:
|
||||
pass # Игнорируем если сообщение не изменилось
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сверки по логам: {e}", exc_info=True)
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_reconcile_logs")
|
||||
@admin_required
|
||||
async def receipts_reconcile_logs_refresh_callback(callback: CallbackQuery):
|
||||
"""Обновить сверку по логам."""
|
||||
await _do_reconcile_logs(callback)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_mon_reconcile_logs_details")
|
||||
@admin_required
|
||||
async def receipts_reconcile_logs_details_callback(callback: CallbackQuery):
|
||||
"""Детальный список платежей без чеков."""
|
||||
try:
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
await callback.answer("🔄 Загружаю детали...", show_alert=False)
|
||||
|
||||
# Путь к логам (logs/current/)
|
||||
log_file_path = Path(settings.LOG_FILE).resolve()
|
||||
log_dir = log_file_path.parent
|
||||
current_dir = log_dir / "current"
|
||||
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
|
||||
|
||||
if not payments_log.exists():
|
||||
await callback.answer("❌ Файл логов не найден", show_alert=True)
|
||||
return
|
||||
|
||||
payment_pattern = re.compile(
|
||||
r"(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}).*Успешно обработан платеж YooKassa ([a-f0-9-]+).*пользователь (\d+).*на ([\d.]+)₽"
|
||||
)
|
||||
receipt_pattern = re.compile(
|
||||
r"Чек NaloGO создан для платежа ([a-f0-9-]+)"
|
||||
)
|
||||
|
||||
payments = {}
|
||||
receipts = set()
|
||||
|
||||
with open(payments_log, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
match = payment_pattern.search(line)
|
||||
if match:
|
||||
date_str, time_str, payment_id, user_id, amount = match.groups()
|
||||
payments[payment_id] = {
|
||||
"date": date_str,
|
||||
"time": time_str,
|
||||
"user_id": user_id,
|
||||
"amount": float(amount)
|
||||
}
|
||||
continue
|
||||
|
||||
match = receipt_pattern.search(line)
|
||||
if match:
|
||||
receipts.add(match.group(1))
|
||||
|
||||
# Платежи без чеков
|
||||
missing = []
|
||||
for payment_id, data in payments.items():
|
||||
if payment_id not in receipts:
|
||||
missing.append({"payment_id": payment_id, **data})
|
||||
|
||||
# Сортируем по дате (новые сверху)
|
||||
missing.sort(key=lambda x: (x["date"], x["time"]), reverse=True)
|
||||
|
||||
if not missing:
|
||||
text = "✅ <b>Все платежи имеют чеки!</b>"
|
||||
else:
|
||||
text = f"📄 <b>Платежи без чеков ({len(missing)} шт.)</b>\n\n"
|
||||
|
||||
for p in missing[:20]:
|
||||
text += (
|
||||
f"• <b>{p['date']} {p['time']}</b>\n"
|
||||
f" User: {p['user_id']} | {p['amount']:.0f}₽\n"
|
||||
f" <code>{p['payment_id'][:18]}...</code>\n\n"
|
||||
)
|
||||
|
||||
if len(missing) > 20:
|
||||
text += f"<i>...и ещё {len(missing) - 20} платежей</i>"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_reconcile_logs")],
|
||||
])
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка детализации: {e}", exc_info=True)
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ def _method_display(method: PaymentMethod) -> str:
|
||||
if method == PaymentMethod.YOOKASSA:
|
||||
return "YooKassa"
|
||||
if method == PaymentMethod.PLATEGA:
|
||||
return "Platega"
|
||||
return settings.get_platega_display_name()
|
||||
if method == PaymentMethod.CRYPTOBOT:
|
||||
return "CryptoBot"
|
||||
if method == PaymentMethod.TELEGRAM_STARS:
|
||||
|
||||
@@ -198,6 +198,7 @@ CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = (
|
||||
choices=(
|
||||
ChoiceOption("selectable", "Выбор пакетов", "Selectable"),
|
||||
ChoiceOption("fixed", "Фиксированный лимит", "Fixed limit"),
|
||||
ChoiceOption("fixed_with_topup", "Фикс. лимит + докупка", "Fixed + topup"),
|
||||
),
|
||||
description_ru="Определяет, выбирают ли пользователи пакеты или получают фиксированный лимит.",
|
||||
description_en="Defines whether users pick packages or use a fixed limit.",
|
||||
@@ -351,8 +352,11 @@ def _format_core_summary(lang_code: str) -> str:
|
||||
base_price = settings.format_price(settings.BASE_SUBSCRIPTION_PRICE)
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
if settings.TRAFFIC_SELECTION_MODE == "fixed":
|
||||
mode = settings.TRAFFIC_SELECTION_MODE.lower()
|
||||
if mode == "fixed":
|
||||
traffic_mode = "⚙️ fixed"
|
||||
elif mode == "fixed_with_topup":
|
||||
traffic_mode = "⚙️ fixed+topup"
|
||||
else:
|
||||
traffic_mode = "⚙️ selectable"
|
||||
traffic_label = _format_traffic_label(traffic_limit, lang_code, short=True)
|
||||
|
||||
+110
-24
@@ -6,7 +6,11 @@ import datetime
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.database.crud.referral import get_referral_statistics, get_user_referral_stats
|
||||
from app.database.crud.referral import (
|
||||
get_referral_statistics,
|
||||
get_top_referrers_by_period,
|
||||
get_user_referral_stats,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
@@ -121,6 +125,39 @@ async def show_referral_statistics(
|
||||
await callback.answer("Произошла ошибка при загрузке статистики")
|
||||
|
||||
|
||||
def _get_top_keyboard(period: str, sort_by: str) -> types.InlineKeyboardMarkup:
|
||||
"""Создаёт клавиатуру для выбора периода и сортировки."""
|
||||
period_week = "✅ Неделя" if period == "week" else "Неделя"
|
||||
period_month = "✅ Месяц" if period == "month" else "Месяц"
|
||||
sort_earnings = "✅ По заработку" if sort_by == "earnings" else "По заработку"
|
||||
sort_invited = "✅ По приглашённым" if sort_by == "invited" else "По приглашённым"
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=period_week,
|
||||
callback_data=f"admin_top_ref:week:{sort_by}"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=period_month,
|
||||
callback_data=f"admin_top_ref:month:{sort_by}"
|
||||
),
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=sort_earnings,
|
||||
callback_data=f"admin_top_ref:{period}:earnings"
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text=sort_invited,
|
||||
callback_data=f"admin_top_ref:{period}:invited"
|
||||
),
|
||||
],
|
||||
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data=f"admin_top_ref:{period}:{sort_by}")],
|
||||
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
|
||||
])
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_top_referrers(
|
||||
@@ -128,27 +165,66 @@ async def show_top_referrers(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Показывает топ рефереров (по умолчанию: неделя, по заработку)."""
|
||||
await _show_top_referrers_filtered(callback, db, period="week", sort_by="earnings")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_top_referrers_filtered(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Обрабатывает выбор периода и сортировки."""
|
||||
# Парсим callback_data: admin_top_ref:period:sort_by
|
||||
parts = callback.data.split(":")
|
||||
if len(parts) != 3:
|
||||
await callback.answer("Ошибка параметров")
|
||||
return
|
||||
|
||||
period = parts[1] # week или month
|
||||
sort_by = parts[2] # earnings или invited
|
||||
|
||||
if period not in ("week", "month"):
|
||||
period = "week"
|
||||
if sort_by not in ("earnings", "invited"):
|
||||
sort_by = "earnings"
|
||||
|
||||
await _show_top_referrers_filtered(callback, db, period, sort_by)
|
||||
|
||||
|
||||
async def _show_top_referrers_filtered(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
period: str,
|
||||
sort_by: str
|
||||
):
|
||||
"""Внутренняя функция отображения топа с фильтрами."""
|
||||
try:
|
||||
stats = await get_referral_statistics(db)
|
||||
top_referrers = stats.get('top_referrers', [])
|
||||
|
||||
text = "🏆 <b>Топ рефереров</b>\n\n"
|
||||
|
||||
top_referrers = await get_top_referrers_by_period(db, period=period, sort_by=sort_by)
|
||||
|
||||
period_text = "за неделю" if period == "week" else "за месяц"
|
||||
sort_text = "по заработку" if sort_by == "earnings" else "по приглашённым"
|
||||
|
||||
text = f"🏆 <b>Топ рефереров {period_text}</b>\n"
|
||||
text += f"<i>Сортировка: {sort_text}</i>\n\n"
|
||||
|
||||
if top_referrers:
|
||||
for i, referrer in enumerate(top_referrers[:20], 1):
|
||||
earned = referrer.get('total_earned_kopeks', 0)
|
||||
count = referrer.get('referrals_count', 0)
|
||||
for i, referrer in enumerate(top_referrers[:20], 1):
|
||||
earned = referrer.get('earnings_kopeks', 0)
|
||||
count = referrer.get('invited_count', 0)
|
||||
display_name = referrer.get('display_name', 'N/A')
|
||||
username = referrer.get('username', '')
|
||||
telegram_id = referrer.get('telegram_id', 'N/A')
|
||||
|
||||
|
||||
if username:
|
||||
display_text = f"@{username} (ID{telegram_id})"
|
||||
elif display_name and display_name != f"ID{telegram_id}":
|
||||
display_text = f"{display_name} (ID{telegram_id})"
|
||||
else:
|
||||
display_text = f"ID{telegram_id}"
|
||||
|
||||
|
||||
emoji = ""
|
||||
if i == 1:
|
||||
emoji = "🥇 "
|
||||
@@ -156,21 +232,30 @@ async def show_top_referrers(
|
||||
emoji = "🥈 "
|
||||
elif i == 3:
|
||||
emoji = "🥉 "
|
||||
|
||||
text += f"{emoji}{i}. {display_text}\n"
|
||||
text += f" 💰 {settings.format_price(earned)} | 👥 {count} реф.\n\n"
|
||||
|
||||
# Выделяем основную метрику в зависимости от сортировки
|
||||
if sort_by == "invited":
|
||||
text += f"{emoji}{i}. {display_text}\n"
|
||||
text += f" 👥 <b>{count} приглашённых</b> | 💰 {settings.format_price(earned)}\n\n"
|
||||
else:
|
||||
text += f"{emoji}{i}. {display_text}\n"
|
||||
text += f" 💰 <b>{settings.format_price(earned)}</b> | 👥 {count} приглашённых\n\n"
|
||||
else:
|
||||
text += "Нет данных о рефererах\n"
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
|
||||
text += "Нет данных за выбранный период\n"
|
||||
|
||||
keyboard = _get_top_keyboard(period, sort_by)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
await callback.answer()
|
||||
except Exception as edit_error:
|
||||
if "message is not modified" in str(edit_error):
|
||||
await callback.answer("Данные актуальны")
|
||||
else:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка в show_top_referrers: {e}", exc_info=True)
|
||||
logger.error(f"Ошибка в show_top_referrers_filtered: {e}", exc_info=True)
|
||||
await callback.answer("Ошибка загрузки топа рефереров")
|
||||
|
||||
|
||||
@@ -210,4 +295,5 @@ async def show_referral_settings(
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
|
||||
dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top")
|
||||
dp.callback_query.register(show_top_referrers_filtered, F.data.startswith("admin_top_ref:"))
|
||||
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
|
||||
|
||||
+168
-52
@@ -26,8 +26,67 @@ from app.utils.cache import RateLimitCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Максимальная длина сообщения Telegram (с запасом)
|
||||
MAX_MESSAGE_LEN = 3500
|
||||
|
||||
|
||||
def _split_long_block(block: str, max_len: int) -> list[str]:
|
||||
"""Разбивает слишком длинный блок на части."""
|
||||
if len(block) <= max_len:
|
||||
return [block]
|
||||
|
||||
parts = []
|
||||
remaining = block
|
||||
while remaining:
|
||||
if len(remaining) <= max_len:
|
||||
parts.append(remaining)
|
||||
break
|
||||
cut_at = max_len
|
||||
newline_pos = remaining.rfind('\n', 0, max_len)
|
||||
space_pos = remaining.rfind(' ', 0, max_len)
|
||||
|
||||
if newline_pos > max_len // 2:
|
||||
cut_at = newline_pos + 1
|
||||
elif space_pos > max_len // 2:
|
||||
cut_at = space_pos + 1
|
||||
|
||||
parts.append(remaining[:cut_at])
|
||||
remaining = remaining[cut_at:]
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int = MAX_MESSAGE_LEN) -> list[str]:
|
||||
"""Разбивает текст на страницы с учётом лимита Telegram."""
|
||||
pages: list[str] = []
|
||||
current = header
|
||||
header_len = len(header)
|
||||
block_max_len = max_len - header_len - 50
|
||||
|
||||
for block in message_blocks:
|
||||
if len(block) > block_max_len:
|
||||
block_parts = _split_long_block(block, block_max_len)
|
||||
for part in block_parts:
|
||||
if len(current) + len(part) > max_len:
|
||||
if current.strip() and current != header:
|
||||
pages.append(current)
|
||||
current = header + part
|
||||
else:
|
||||
current += part
|
||||
elif len(current) + len(block) > max_len:
|
||||
if current.strip() and current != header:
|
||||
pages.append(current)
|
||||
current = header + block
|
||||
else:
|
||||
current += block
|
||||
|
||||
if current.strip():
|
||||
pages.append(current)
|
||||
|
||||
return pages if pages else [header]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def show_admin_tickets(
|
||||
@@ -133,15 +192,27 @@ async def view_admin_ticket(
|
||||
state: Optional[FSMContext] = None,
|
||||
ticket_id: Optional[int] = None
|
||||
):
|
||||
"""Показать детали тикета для админа"""
|
||||
"""Показать детали тикета для админа с пагинацией"""
|
||||
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
|
||||
return
|
||||
|
||||
if ticket_id is None:
|
||||
|
||||
# Парсим ticket_id и page из callback_data
|
||||
page = 1
|
||||
data_str = callback.data or ""
|
||||
|
||||
if data_str.startswith("admin_ticket_page_"):
|
||||
# format: admin_ticket_page_{ticket_id}_{page}
|
||||
try:
|
||||
ticket_id = int((callback.data or "").split("_")[-1])
|
||||
parts = data_str.split("_")
|
||||
ticket_id = int(parts[3])
|
||||
page = max(1, int(parts[4]))
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
elif ticket_id is None:
|
||||
try:
|
||||
ticket_id = int(data_str.split("_")[-1])
|
||||
except (ValueError, AttributeError):
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
@@ -152,9 +223,9 @@ async def view_admin_ticket(
|
||||
|
||||
if state is None:
|
||||
state = FSMContext(callback.bot, callback.from_user.id)
|
||||
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True)
|
||||
|
||||
|
||||
if not ticket:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
@@ -162,59 +233,60 @@ async def view_admin_ticket(
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Формируем текст тикета
|
||||
|
||||
# Формируем заголовок тикета
|
||||
status_text = {
|
||||
TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"),
|
||||
TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"),
|
||||
TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"),
|
||||
TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании")
|
||||
}.get(ticket.status, ticket.status)
|
||||
|
||||
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
telegram_id_display = ticket.user.telegram_id if ticket.user else "—"
|
||||
username_value = ticket.user.username if ticket.user else None
|
||||
|
||||
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
|
||||
ticket_text += f"👤 Пользователь: {user_name}\n"
|
||||
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
|
||||
header = f"🎫 Тикет #{ticket.id}\n\n"
|
||||
header += f"👤 Пользователь: {user_name}\n"
|
||||
header += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
|
||||
if username_value:
|
||||
safe_username = html.escape(username_value)
|
||||
ticket_text += f"📱 Username: @{safe_username}\n"
|
||||
ticket_text += (
|
||||
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
|
||||
f"tg://resolve?domain={safe_username}</a>\n"
|
||||
)
|
||||
header += f"📱 Username: @{safe_username}\n"
|
||||
else:
|
||||
ticket_text += "📱 Username: отсутствует\n"
|
||||
if ticket.user and ticket.user.telegram_id:
|
||||
chat_link = f"tg://user?id={int(ticket.user.telegram_id)}"
|
||||
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
|
||||
ticket_text += "\n"
|
||||
ticket_text += f"📝 Заголовок: {ticket.title}\n"
|
||||
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
ticket_text += f"🔄 Обновлен: {ticket.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
header += "📱 Username: отсутствует\n"
|
||||
header += f"📝 Заголовок: {ticket.title}\n"
|
||||
header += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
|
||||
header += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
if ticket.is_user_reply_blocked:
|
||||
if ticket.user_reply_block_permanent:
|
||||
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
|
||||
header += "🚫 Пользователь заблокирован навсегда\n\n"
|
||||
elif ticket.user_reply_block_until:
|
||||
ticket_text += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
|
||||
header += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
# Формируем блоки сообщений
|
||||
message_blocks: list[str] = []
|
||||
if ticket.messages:
|
||||
ticket_text += f"💬 Сообщения ({len(ticket.messages)}):\n\n"
|
||||
|
||||
message_blocks.append(f"💬 Сообщения ({len(ticket.messages)}):\n\n")
|
||||
for msg in ticket.messages:
|
||||
sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка"
|
||||
ticket_text += f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
|
||||
ticket_text += f"{msg.message_text}\n\n"
|
||||
block = (
|
||||
f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
|
||||
f"{msg.message_text}\n\n"
|
||||
)
|
||||
if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo":
|
||||
ticket_text += "📎 Вложение: фото\n\n"
|
||||
|
||||
# Добавим кнопку "Вложения", если есть фото
|
||||
block += "📎 Вложение: фото\n\n"
|
||||
message_blocks.append(block)
|
||||
|
||||
# Разбиваем на страницы
|
||||
pages = _split_text_into_pages(header, message_blocks, max_len=MAX_MESSAGE_LEN)
|
||||
total_pages = len(pages)
|
||||
if page > total_pages:
|
||||
page = total_pages
|
||||
|
||||
# Формируем клавиатуру
|
||||
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or [])
|
||||
keyboard = get_admin_ticket_view_keyboard(
|
||||
ticket_id,
|
||||
@@ -222,7 +294,8 @@ async def view_admin_ticket(
|
||||
db_user.language,
|
||||
is_user_blocked=ticket.is_user_reply_blocked
|
||||
)
|
||||
# Кнопка открытия профиля пользователя в админке
|
||||
|
||||
# Кнопка профиля пользователя
|
||||
try:
|
||||
if ticket.user:
|
||||
admin_profile_btn = types.InlineKeyboardButton(
|
||||
@@ -232,34 +305,76 @@ async def view_admin_ticket(
|
||||
keyboard.inline_keyboard.insert(0, [admin_profile_btn])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Кнопки ЛС и профиль
|
||||
try:
|
||||
if ticket.user and ticket.user.telegram_id and ticket.user.username:
|
||||
safe_username = html.escape(ticket.user.username)
|
||||
buttons_row = []
|
||||
pm_url = f"tg://resolve?domain={safe_username}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
|
||||
buttons_row.append(types.InlineKeyboardButton(text="✉ ЛС", url=pm_url))
|
||||
profile_url = f"tg://user?id={ticket.user.telegram_id}"
|
||||
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
|
||||
if buttons_row:
|
||||
keyboard.inline_keyboard.insert(0, buttons_row)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Кнопка вложений
|
||||
if has_photos:
|
||||
try:
|
||||
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")])
|
||||
keyboard.inline_keyboard.insert(0, [
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"),
|
||||
callback_data=f"admin_ticket_attachments_{ticket_id}"
|
||||
)
|
||||
])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Рендер через фото-утилиту (с логотипом), внутри есть фоллбеки на текст
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=ticket_text,
|
||||
keyboard=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
# сохраняем id для дальнейших действий (ответ/статусы)
|
||||
# Пагинация
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
if page > 1:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"admin_ticket_page_{ticket_id}_{page - 1}"
|
||||
))
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text=f"{page}/{total_pages}",
|
||||
callback_data="noop"
|
||||
))
|
||||
if page < total_pages:
|
||||
nav_row.append(types.InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"admin_ticket_page_{ticket_id}_{page + 1}"
|
||||
))
|
||||
try:
|
||||
keyboard.inline_keyboard.insert(0, nav_row)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
page_text = pages[page - 1]
|
||||
|
||||
# Отправка сообщения
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
page_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
page_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Сохраняем id для дальнейших действий
|
||||
if state is not None:
|
||||
try:
|
||||
await state.update_data(ticket_id=ticket_id)
|
||||
@@ -1034,7 +1149,8 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open")
|
||||
|
||||
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
|
||||
|
||||
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_ticket_page_"))
|
||||
|
||||
# Ответы на тикеты
|
||||
dp.callback_query.register(
|
||||
reply_to_admin_ticket,
|
||||
|
||||
+354
-2
@@ -24,7 +24,8 @@ from app.database.crud.campaign import (
|
||||
from app.keyboards.admin import (
|
||||
get_admin_users_keyboard, get_user_management_keyboard,
|
||||
get_admin_pagination_keyboard, get_confirmation_keyboard,
|
||||
get_admin_users_filters_keyboard, get_user_promo_group_keyboard
|
||||
get_admin_users_filters_keyboard, get_user_promo_group_keyboard,
|
||||
get_user_restrictions_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_service import UserService
|
||||
@@ -1241,6 +1242,15 @@ async def _render_user_subscription_overview(
|
||||
]
|
||||
]
|
||||
|
||||
if settings.is_modem_enabled():
|
||||
modem_status = "✅" if getattr(subscription, 'modem_enabled', False) else "❌"
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"📡 Модем ({modem_status})",
|
||||
callback_data=f"admin_user_modem_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if subscription.is_active:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
@@ -1633,6 +1643,20 @@ async def show_user_management(
|
||||
else:
|
||||
sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
|
||||
|
||||
# Показать ограничения пользователя если есть
|
||||
restriction_topup = getattr(user, 'restriction_topup', False)
|
||||
restriction_subscription = getattr(user, 'restriction_subscription', False)
|
||||
if restriction_topup or restriction_subscription:
|
||||
restriction_lines = ["⚠️ <b>Ограничения:</b>"]
|
||||
if restriction_topup:
|
||||
restriction_lines.append(" • 🚫 Пополнение запрещено")
|
||||
if restriction_subscription:
|
||||
restriction_lines.append(" • 🚫 Продление/покупка запрещена")
|
||||
restriction_reason = getattr(user, 'restriction_reason', None)
|
||||
if restriction_reason:
|
||||
restriction_lines.append(f" 📝 Причина: {restriction_reason}")
|
||||
sections.append("\n".join(restriction_lines))
|
||||
|
||||
text = "\n\n".join(sections)
|
||||
|
||||
# Проверяем состояние, чтобы определить, откуда пришел пользователь
|
||||
@@ -2746,6 +2770,236 @@ async def block_user(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# ============ УПРАВЛЕНИЕ ОГРАНИЧЕНИЯМИ ПОЛЬЗОВАТЕЛЯ ============
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_restrictions(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Показать меню управления ограничениями пользователя."""
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
await callback.answer("Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Формируем текст с информацией об ограничениях
|
||||
restriction_topup = getattr(user, 'restriction_topup', False)
|
||||
restriction_subscription = getattr(user, 'restriction_subscription', False)
|
||||
restriction_reason = getattr(user, 'restriction_reason', None)
|
||||
|
||||
text_lines = [
|
||||
f"⚠️ <b>Ограничения пользователя</b>",
|
||||
f"👤 {user.full_name}",
|
||||
"",
|
||||
f"✅ — разрешено, 🚫 — запрещено",
|
||||
"",
|
||||
f"{'🚫' if restriction_topup else '✅'} Пополнение баланса",
|
||||
f"{'🚫' if restriction_subscription else '✅'} Продление/покупка подписки",
|
||||
]
|
||||
|
||||
if restriction_reason:
|
||||
text_lines.append("")
|
||||
text_lines.append(f"📝 <b>Причина:</b> {restriction_reason}")
|
||||
|
||||
keyboard = get_user_restrictions_keyboard(
|
||||
user_id=user_id,
|
||||
restriction_topup=restriction_topup,
|
||||
restriction_subscription=restriction_subscription,
|
||||
language=db_user.language
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_lines),
|
||||
reply_markup=keyboard
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_restriction_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Переключить ограничение на пополнение баланса."""
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
await callback.answer("Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Переключаем ограничение
|
||||
current_value = getattr(user, 'restriction_topup', False)
|
||||
user.restriction_topup = not current_value
|
||||
await db.commit()
|
||||
|
||||
action = "установлено" if user.restriction_topup else "снято"
|
||||
await callback.answer(f"Ограничение на пополнение {action}", show_alert=False)
|
||||
|
||||
# Обновляем меню
|
||||
await show_user_restrictions(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_restriction_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Переключить ограничение на продление/покупку подписки."""
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
await callback.answer("Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Переключаем ограничение
|
||||
current_value = getattr(user, 'restriction_subscription', False)
|
||||
user.restriction_subscription = not current_value
|
||||
await db.commit()
|
||||
|
||||
action = "установлено" if user.restriction_subscription else "снято"
|
||||
await callback.answer(f"Ограничение на подписку {action}", show_alert=False)
|
||||
|
||||
# Обновляем меню
|
||||
await show_user_restrictions(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def ask_restriction_reason(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
"""Запросить ввод причины ограничения."""
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
await callback.answer("Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
current_reason = getattr(user, 'restriction_reason', None) or ""
|
||||
|
||||
await state.set_state(AdminStates.editing_user_restriction_reason)
|
||||
await state.update_data(restriction_user_id=user_id)
|
||||
|
||||
text = (
|
||||
"📝 <b>Введите причину ограничения</b>\n\n"
|
||||
"Эта причина будет показана пользователю при попытке "
|
||||
"выполнить запрещённое действие.\n\n"
|
||||
)
|
||||
if current_reason:
|
||||
text += f"Текущая причина: <i>{current_reason}</i>\n\n"
|
||||
text += "Отправьте новую причину или /cancel для отмены:"
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_restrictions_{user_id}")]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def save_restriction_reason(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
"""Сохранить причину ограничения."""
|
||||
data = await state.get_data()
|
||||
user_id = data.get("restriction_user_id")
|
||||
|
||||
if not user_id:
|
||||
await message.answer("Ошибка: пользователь не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await message.answer("Ошибка: пользователь не найден")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
reason = message.text.strip()[:500] # Ограничение 500 символов
|
||||
user.restriction_reason = reason
|
||||
await db.commit()
|
||||
|
||||
await state.clear()
|
||||
|
||||
# Формируем текст с обновлённой информацией
|
||||
restriction_topup = getattr(user, 'restriction_topup', False)
|
||||
restriction_subscription = getattr(user, 'restriction_subscription', False)
|
||||
|
||||
text_lines = [
|
||||
f"✅ <b>Причина ограничения сохранена</b>",
|
||||
"",
|
||||
f"⚠️ <b>Ограничения пользователя</b>",
|
||||
f"👤 {user.full_name}",
|
||||
"",
|
||||
f"{'🚫' if restriction_topup else '✅'} Пополнение баланса",
|
||||
f"{'🚫' if restriction_subscription else '✅'} Продление/покупка подписки",
|
||||
"",
|
||||
f"📝 <b>Причина:</b> {reason}",
|
||||
]
|
||||
|
||||
keyboard = get_user_restrictions_keyboard(
|
||||
user_id=user_id,
|
||||
restriction_topup=restriction_topup,
|
||||
restriction_subscription=restriction_subscription,
|
||||
language=db_user.language
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
"\n".join(text_lines),
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_user_restrictions(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Снять все ограничения с пользователя."""
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
if not user:
|
||||
await callback.answer("Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
# Снимаем все ограничения
|
||||
user.restriction_topup = False
|
||||
user.restriction_subscription = False
|
||||
user.restriction_reason = None
|
||||
await db.commit()
|
||||
|
||||
await callback.answer("Все ограничения сняты", show_alert=True)
|
||||
|
||||
# Обновляем меню
|
||||
await show_user_restrictions(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_inactive_users(
|
||||
@@ -3803,6 +4057,68 @@ async def set_user_devices_button(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_user_modem(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Переключение модема для пользователя в админке."""
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
subscription = user.subscription
|
||||
if not subscription:
|
||||
await callback.answer("❌ У пользователя нет подписки", show_alert=True)
|
||||
return
|
||||
|
||||
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
if modem_enabled:
|
||||
# Отключаем модем
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
action_text = "отключен"
|
||||
else:
|
||||
# Включаем модем
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
action_text = "подключен"
|
||||
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
# Обновляем в RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления RemnaWave при переключении модема: {e}")
|
||||
|
||||
await db.refresh(subscription)
|
||||
|
||||
modem_status = "✅ Подключен" if subscription.modem_enabled else "❌ Отключен"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📡 <b>Модем {action_text}</b>\n\n"
|
||||
f"Статус модема: {modem_status}\n"
|
||||
f"Лимит устройств: {subscription.device_limit}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
|
||||
]),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
logger.info(f"Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_devices_edit_text(
|
||||
@@ -4978,7 +5294,38 @@ def register_handlers(dp: Dispatcher):
|
||||
confirm_user_delete,
|
||||
F.data.startswith("admin_user_delete_") & ~F.data.contains("confirm")
|
||||
)
|
||||
|
||||
|
||||
# Регистрация хендлеров ограничений пользователя
|
||||
dp.callback_query.register(
|
||||
show_user_restrictions,
|
||||
F.data.startswith("admin_user_restrictions_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_user_restriction_topup,
|
||||
F.data.startswith("admin_user_restriction_toggle_topup_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_user_restriction_subscription,
|
||||
F.data.startswith("admin_user_restriction_toggle_sub_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
ask_restriction_reason,
|
||||
F.data.startswith("admin_user_restriction_reason_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
clear_user_restrictions,
|
||||
F.data.startswith("admin_user_restriction_clear_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
save_restriction_reason,
|
||||
AdminStates.editing_user_restriction_reason
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_users_list_pagination_fixed,
|
||||
F.data.startswith("admin_users_list_page_")
|
||||
@@ -5198,6 +5545,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data.startswith("admin_user_devices_set_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
toggle_user_modem,
|
||||
F.data.startswith("admin_user_modem_")
|
||||
)
|
||||
|
||||
dp.message.register(
|
||||
process_devices_edit_text,
|
||||
AdminStates.editing_user_devices
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
"""Handler for CloudPayments balance top-up."""
|
||||
|
||||
import logging
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.states import BalanceStates
|
||||
from app.utils.decorators import error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _create_cloudpayments_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
):
|
||||
"""
|
||||
Common logic for creating CloudPayments payment and sending response.
|
||||
|
||||
Args:
|
||||
message_or_callback: Either a Message or CallbackQuery object
|
||||
db_user: User object
|
||||
db: Database session
|
||||
amount_kopeks: Amount in kopeks
|
||||
edit_message: Whether to edit existing message or send new one
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description=settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
)
|
||||
|
||||
result = await payment_service.create_cloudpayments_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
telegram_id=db_user.telegram_id,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
error_text = texts.t(
|
||||
"PAYMENT_CREATE_ERROR",
|
||||
"Не удалось создать платёж. Попробуйте позже.",
|
||||
)
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
error_text,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get("payment_url")
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"PAY_BUTTON",
|
||||
"💳 Оплатить {amount}₽",
|
||||
).format(amount=f"{amount_rub:.0f}"),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_BUTTON", "◀️ Назад"),
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
response_text = texts.t(
|
||||
"CLOUDPAYMENTS_PAYMENT_CREATED",
|
||||
"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
"Сумма: <b>{amount}₽</b>\n\n"
|
||||
"Нажмите кнопку ниже для оплаты.\n"
|
||||
"После успешной оплаты баланс будет пополнен автоматически.",
|
||||
).format(amount=f"{amount_rub:.2f}")
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await message_or_callback.answer(
|
||||
response_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"CloudPayments payment created: user=%s, amount=%s₽",
|
||||
db_user.telegram_id,
|
||||
amount_rub,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_cloudpayments_payment_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
|
||||
Similar to process_heleket_payment_amount and other payment handlers.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not settings.is_cloudpayments_enabled():
|
||||
await message.answer(
|
||||
texts.t("CLOUDPAYMENTS_NOT_AVAILABLE", "CloudPayments временно недоступен"),
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
|
||||
min_rub = settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"AMOUNT_TOO_LOW",
|
||||
"Минимальная сумма пополнения: {min_amount:.0f}₽",
|
||||
).format(min_amount=min_rub),
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
|
||||
max_rub = settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"AMOUNT_TOO_HIGH",
|
||||
"Максимальная сумма пополнения: {max_amount:,.0f}₽",
|
||||
).format(max_amount=max_rub),
|
||||
)
|
||||
return
|
||||
|
||||
# Clear state
|
||||
await state.clear()
|
||||
|
||||
await _create_cloudpayments_payment_and_respond(
|
||||
message, db_user, db, amount_kopeks, edit_message=False
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_cloudpayments_payment(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Start CloudPayments payment flow.
|
||||
|
||||
Shows amount input prompt or quick amount buttons.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if not settings.is_cloudpayments_enabled():
|
||||
await callback.answer(
|
||||
texts.t("CLOUDPAYMENTS_NOT_AVAILABLE", "CloudPayments временно недоступен"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
min_amount_rub = settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS / 100
|
||||
max_amount_rub = settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS / 100
|
||||
|
||||
message_text = texts.t(
|
||||
"CLOUDPAYMENTS_ENTER_AMOUNT",
|
||||
"💳 <b>Оплата банковской картой (CloudPayments)</b>\n\n"
|
||||
"Введите сумму для пополнения от {min_amount:.0f} до {max_amount:,.0f} рублей:",
|
||||
).format(min_amount=min_amount_rub, max_amount=max_amount_rub)
|
||||
|
||||
keyboard = get_back_keyboard(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method="cloudpayments")
|
||||
await state.update_data(
|
||||
cloudpayments_prompt_message_id=callback.message.message_id,
|
||||
cloudpayments_prompt_chat_id=callback.message.chat.id,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_cloudpayments_amount(
|
||||
message: types.Message,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Process entered amount and create CloudPayments payment.
|
||||
|
||||
Generates payment link and sends it to user.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Get state data
|
||||
state_data = await state.get_data()
|
||||
payment_method = state_data.get("payment_method")
|
||||
|
||||
if payment_method != "cloudpayments":
|
||||
return # Not our payment method
|
||||
|
||||
# Parse amount
|
||||
try:
|
||||
amount_text = message.text.strip().replace(",", ".").replace(" ", "")
|
||||
amount_rub = float(amount_text)
|
||||
amount_kopeks = int(amount_rub * 100)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(
|
||||
texts.t("INVALID_AMOUNT", "Введите корректную сумму числом"),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
# Validate amount
|
||||
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
|
||||
min_rub = settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"AMOUNT_TOO_LOW",
|
||||
"Минимальная сумма пополнения: {min_amount:.0f}₽",
|
||||
).format(min_amount=min_rub),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
|
||||
max_rub = settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS / 100
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"AMOUNT_TOO_HIGH",
|
||||
"Максимальная сумма пополнения: {max_amount:,.0f}₽",
|
||||
).format(max_amount=max_rub),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
# Clear state
|
||||
await state.clear()
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description=settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
)
|
||||
|
||||
result = await payment_service.create_cloudpayments_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
telegram_id=db_user.telegram_id,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"PAYMENT_CREATE_ERROR",
|
||||
"Не удалось создать платёж. Попробуйте позже.",
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get("payment_url")
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"PAY_BUTTON",
|
||||
"💳 Оплатить {amount}₽",
|
||||
).format(amount=f"{amount_rub:.0f}"),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_BUTTON", "◀️ Назад"),
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
texts.t(
|
||||
"CLOUDPAYMENTS_PAYMENT_CREATED",
|
||||
"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
"Сумма: <b>{amount}₽</b>\n\n"
|
||||
"Нажмите кнопку ниже для оплаты.\n"
|
||||
"После успешной оплаты баланс будет пополнен автоматически.",
|
||||
).format(amount=f"{amount_rub:.2f}"),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"CloudPayments payment created: user=%s, amount=%s₽",
|
||||
db_user.telegram_id,
|
||||
amount_rub,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def handle_cloudpayments_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
"""
|
||||
Handle quick amount selection for CloudPayments.
|
||||
|
||||
Called when user clicks a predefined amount button.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not settings.is_cloudpayments_enabled():
|
||||
await callback.answer(
|
||||
texts.t("CLOUDPAYMENTS_NOT_AVAILABLE", "CloudPayments временно недоступен"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|cloudpayments|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split("|")
|
||||
if len(parts) >= 3:
|
||||
amount_kopeks = int(parts[2])
|
||||
else:
|
||||
await callback.answer("Invalid callback data", show_alert=True)
|
||||
return
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Invalid amount", show_alert=True)
|
||||
return
|
||||
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
# Validate amount
|
||||
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
texts.t("AMOUNT_TOO_LOW_SHORT", "Сумма слишком мала"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
|
||||
await callback.answer(
|
||||
texts.t("AMOUNT_TOO_HIGH_SHORT", "Сумма слишком велика"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
|
||||
service_name=settings.PAYMENT_SERVICE_NAME,
|
||||
description=settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
)
|
||||
|
||||
result = await payment_service.create_cloudpayments_payment(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
telegram_id=db_user.telegram_id,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
if not result:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"PAYMENT_CREATE_ERROR",
|
||||
"Не удалось создать платёж. Попробуйте позже.",
|
||||
),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return
|
||||
|
||||
payment_url = result.get("payment_url")
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"PAY_BUTTON",
|
||||
"💳 Оплатить {amount}₽",
|
||||
).format(amount=f"{amount_rub:.0f}"),
|
||||
url=payment_url,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BACK_BUTTON", "◀️ Назад"),
|
||||
callback_data="menu_balance",
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"CLOUDPAYMENTS_PAYMENT_CREATED",
|
||||
"💳 <b>Оплата банковской картой</b>\n\n"
|
||||
"Сумма: <b>{amount}₽</b>\n\n"
|
||||
"Нажмите кнопку ниже для оплаты.\n"
|
||||
"После успешной оплаты баланс будет пополнен автоматически.",
|
||||
).format(amount=f"{amount_rub:.2f}"),
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"CloudPayments payment created (quick): user=%s, amount=%s₽",
|
||||
db_user.telegram_id,
|
||||
amount_rub,
|
||||
)
|
||||
@@ -22,7 +22,24 @@ async def start_cryptobot_payment(
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if not settings.is_cryptobot_enabled():
|
||||
await callback.answer("❌ Оплата криптовалютой временно недоступна", show_alert=True)
|
||||
return
|
||||
@@ -97,6 +114,26 @@ async def process_cryptobot_payment_amount(
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id,
|
||||
|
||||
@@ -26,6 +26,23 @@ async def start_heleket_payment(
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if not settings.is_heleket_enabled():
|
||||
await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True)
|
||||
return
|
||||
@@ -86,6 +103,24 @@ async def process_heleket_payment_amount(
|
||||
) -> None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not settings.is_heleket_enabled():
|
||||
await message.answer("❌ Оплата через Heleket недоступна")
|
||||
return
|
||||
|
||||
@@ -242,6 +242,24 @@ async def show_payment_methods(
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
# Добавляем информацию о текущем тарифе пользователя
|
||||
@@ -594,6 +612,11 @@ async def process_topup_amount(
|
||||
from .heleket import process_heleket_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
elif payment_method == "cloudpayments":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cloudpayments import process_cloudpayments_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cloudpayments_amount(message, db_user, db, state)
|
||||
else:
|
||||
await message.answer("Неизвестный способ оплаты")
|
||||
|
||||
@@ -723,6 +746,14 @@ async def handle_quick_amount_selection(
|
||||
await process_heleket_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "cloudpayments":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cloudpayments import process_cloudpayments_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cloudpayments_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif payment_method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
|
||||
@@ -822,6 +853,13 @@ async def handle_topup_amount_callback(
|
||||
await process_wata_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "cloudpayments":
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from .cloudpayments import process_cloudpayments_payment_amount
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_cloudpayments_payment_amount(
|
||||
callback.message, db_user, db, amount_kopeks, state
|
||||
)
|
||||
elif method == "stars":
|
||||
from .stars import process_stars_payment_amount
|
||||
await process_stars_payment_amount(
|
||||
@@ -965,6 +1003,16 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
F.data.startswith("check_heleket_")
|
||||
)
|
||||
|
||||
from .cloudpayments import start_cloudpayments_payment, handle_cloudpayments_quick_amount
|
||||
dp.callback_query.register(
|
||||
start_cloudpayments_payment,
|
||||
F.data == "topup_cloudpayments"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_cloudpayments_quick_amount,
|
||||
F.data.startswith("topup_amount|cloudpayments|")
|
||||
)
|
||||
|
||||
from .mulenpay import check_mulenpay_payment_status
|
||||
dp.callback_query.register(
|
||||
check_mulenpay_payment_status,
|
||||
|
||||
@@ -21,6 +21,24 @@ async def start_mulenpay_payment(
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
|
||||
@@ -76,6 +94,25 @@ async def process_mulenpay_payment_amount(
|
||||
state: FSMContext,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
mulenpay_name = settings.get_mulenpay_display_name()
|
||||
mulenpay_name_html = settings.get_mulenpay_display_name_html()
|
||||
|
||||
|
||||
@@ -264,6 +264,23 @@ async def start_pal24_payment(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
|
||||
return
|
||||
@@ -321,6 +338,24 @@ async def process_pal24_payment_amount(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not settings.is_pal24_enabled():
|
||||
await message.answer("❌ Оплата через PayPalych временно недоступна")
|
||||
return
|
||||
|
||||
@@ -112,6 +112,23 @@ async def start_platega_payment(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
@@ -200,6 +217,24 @@ async def process_platega_payment_amount(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not settings.is_platega_enabled():
|
||||
await message.answer(
|
||||
texts.t(
|
||||
|
||||
@@ -27,6 +27,23 @@ async def start_stars_payment(
|
||||
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Формируем текст сообщения в зависимости от настройки
|
||||
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
|
||||
message_text = (
|
||||
@@ -69,6 +86,26 @@ async def process_stars_payment_amount(
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id,
|
||||
|
||||
@@ -17,6 +17,23 @@ async def start_tribute_payment(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if not settings.TRIBUTE_ENABLED:
|
||||
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -26,6 +26,23 @@ async def start_wata_payment(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if not settings.is_wata_enabled():
|
||||
await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True)
|
||||
return
|
||||
@@ -76,6 +93,24 @@ async def process_wata_payment_amount(
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if not settings.is_wata_enabled():
|
||||
await message.answer("❌ Оплата через WATA временно недоступна")
|
||||
return
|
||||
|
||||
@@ -25,7 +25,24 @@ async def start_yookassa_payment(
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if not settings.is_yookassa_enabled():
|
||||
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
|
||||
return
|
||||
@@ -79,7 +96,24 @@ async def start_yookassa_sbp_payment(
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
|
||||
await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True)
|
||||
return
|
||||
@@ -134,6 +168,26 @@ async def process_yookassa_payment_amount(
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id,
|
||||
@@ -280,6 +334,26 @@ async def process_yookassa_sbp_payment_amount(
|
||||
amount_kopeks: int,
|
||||
state: FSMContext
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
|
||||
|
||||
await message.answer(
|
||||
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Проверяем, находится ли пользователь в черном списке
|
||||
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
|
||||
message.from_user.id,
|
||||
|
||||
@@ -67,7 +67,7 @@ async def handle_cancel(
|
||||
|
||||
async def handle_unknown_message(
|
||||
message: types.Message,
|
||||
db_user: User
|
||||
db_user: User | None = None,
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language if db_user else "ru")
|
||||
@@ -126,6 +126,8 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.message.register(
|
||||
handle_unknown_message,
|
||||
StateFilter(None),
|
||||
F.successful_payment.is_(None)
|
||||
F.successful_payment.is_(None),
|
||||
F.text.is_not(None),
|
||||
~F.text.startswith("/"),
|
||||
)
|
||||
|
||||
+209
-282
@@ -1,41 +1,73 @@
|
||||
"""Contest handlers for daily games."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import (
|
||||
get_active_rounds,
|
||||
get_attempt,
|
||||
create_attempt,
|
||||
increment_winner_count,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ContestRound, ContestTemplate, SubscriptionStatus
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.contest_rotation_service import (
|
||||
GAME_QUEST,
|
||||
GAME_LOCKS,
|
||||
GAME_CIPHER,
|
||||
GAME_SERVER,
|
||||
GAME_BLITZ,
|
||||
GAME_EMOJI,
|
||||
GAME_ANAGRAM,
|
||||
)
|
||||
from app.database.crud.contest import get_active_rounds, get_attempt
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.subscription import extend_subscription
|
||||
from app.utils.decorators import auth_required, error_handler
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ContestRound, SubscriptionStatus
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.contests import (
|
||||
ContestAttemptService,
|
||||
GameType,
|
||||
get_game_strategy,
|
||||
)
|
||||
from app.states import ContestStates
|
||||
from app.utils.decorators import auth_required, error_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Rate limiting storage
|
||||
_rate_limits: dict = {}
|
||||
|
||||
# Service instance
|
||||
_attempt_service = ContestAttemptService()
|
||||
|
||||
|
||||
def _check_rate_limit(user_id: int, action: str, limit: int = 1, window_seconds: int = 5) -> bool:
|
||||
"""Check if user exceeds rate limit for contest actions."""
|
||||
key = f"{user_id}_{action}"
|
||||
now = datetime.utcnow().timestamp()
|
||||
|
||||
if key not in _rate_limits:
|
||||
_rate_limits[key] = []
|
||||
|
||||
# Clean old entries
|
||||
_rate_limits[key] = [t for t in _rate_limits[key] if now - t < window_seconds]
|
||||
|
||||
if len(_rate_limits[key]) >= limit:
|
||||
return False
|
||||
|
||||
_rate_limits[key].append(now)
|
||||
return True
|
||||
|
||||
|
||||
def _validate_callback_data(data: str) -> Optional[list]:
|
||||
"""Validate and parse callback data safely."""
|
||||
if not data or not isinstance(data, str):
|
||||
return None
|
||||
|
||||
parts = data.split("_")
|
||||
if len(parts) < 2 or parts[0] != "contest":
|
||||
return None
|
||||
|
||||
for part in parts:
|
||||
if not part or len(part) > 50:
|
||||
return None
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def _user_allowed(subscription) -> bool:
|
||||
"""Check if user has active or trial subscription."""
|
||||
if not subscription:
|
||||
return False
|
||||
return subscription.status in {
|
||||
@@ -44,22 +76,13 @@ def _user_allowed(subscription) -> bool:
|
||||
}
|
||||
|
||||
|
||||
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
|
||||
from app.database.crud.user import get_user_by_id
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return ""
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
return ""
|
||||
await extend_subscription(db, subscription, prize_days)
|
||||
texts = get_texts(language)
|
||||
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
|
||||
|
||||
|
||||
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
|
||||
"""Reply that user is not eligible to play."""
|
||||
texts = get_texts(language)
|
||||
await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True)
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Handlers ----------
|
||||
@@ -68,13 +91,17 @@ async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession):
|
||||
"""Show menu with available contest games."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await _reply_not_eligible(callback, db_user.language)
|
||||
return
|
||||
|
||||
active_rounds = await get_active_rounds(db)
|
||||
|
||||
# Group by template, take one round per template
|
||||
unique_templates = {}
|
||||
for rnd in active_rounds:
|
||||
if not rnd.template or not rnd.template.is_enabled:
|
||||
@@ -86,19 +113,24 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe
|
||||
buttons = []
|
||||
for tpl_slug, rnd in unique_templates.items():
|
||||
title = rnd.template.name if rnd.template else tpl_slug
|
||||
buttons.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f"▶️ {title}",
|
||||
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"▶️ {title}",
|
||||
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
|
||||
)
|
||||
])
|
||||
|
||||
if not buttons:
|
||||
buttons.append(
|
||||
[types.InlineKeyboardButton(text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), callback_data="noop")]
|
||||
)
|
||||
buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"),
|
||||
callback_data="noop",
|
||||
)
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_MENU_TITLE", "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:"),
|
||||
@@ -110,14 +142,25 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession):
|
||||
"""Start playing a specific contest."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await _reply_not_eligible(callback, db_user.language)
|
||||
return
|
||||
|
||||
parts = callback.data.split("_")
|
||||
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "play":
|
||||
# Rate limit check
|
||||
if not _check_rate_limit(db_user.id, "contest_play", limit=2, window_seconds=10):
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate callback data
|
||||
parts = _validate_callback_data(callback.data)
|
||||
if not parts or len(parts) < 4 or parts[1] != "play":
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -128,285 +171,169 @@ async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
slug = "_".join(parts[2:-1])
|
||||
|
||||
# reload round with template
|
||||
# Get round with template
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
|
||||
if not round_obj:
|
||||
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), show_alert=True)
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if not round_obj.template or not round_obj.template.is_enabled:
|
||||
await callback.answer(texts.t("CONTEST_DISABLED", "Игра отключена."), show_alert=True)
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_DISABLED", "Игра отключена."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Check if user already played
|
||||
attempt = await get_attempt(db2, round_id, db_user.id)
|
||||
if attempt:
|
||||
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), show_alert=True)
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Get game strategy and render
|
||||
tpl = round_obj.template
|
||||
if tpl.slug == GAME_QUEST:
|
||||
await _render_quest(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_LOCKS:
|
||||
await _render_locks(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_SERVER:
|
||||
await _render_server_lottery(callback, db_user, round_obj, tpl)
|
||||
elif tpl.slug == GAME_CIPHER:
|
||||
await _render_cipher(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_EMOJI:
|
||||
await _render_emoji(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_ANAGRAM:
|
||||
await _render_anagram(callback, db_user, round_obj, tpl, state)
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
await _render_blitz(callback, db_user, round_obj, tpl)
|
||||
else:
|
||||
await callback.answer(texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), show_alert=True)
|
||||
strategy = get_game_strategy(tpl.slug)
|
||||
|
||||
|
||||
async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
rows = round_obj.payload.get("rows", 3)
|
||||
cols = round_obj.payload.get("cols", 3)
|
||||
keyboard = []
|
||||
for r in range(rows):
|
||||
row_buttons = []
|
||||
for c in range(cols):
|
||||
idx = r * cols + c
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="🎛",
|
||||
callback_data=f"contest_pick_{round_obj.id}_quest_{idx}"
|
||||
)
|
||||
if not strategy:
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."),
|
||||
show_alert=True,
|
||||
)
|
||||
keyboard.append(row_buttons)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
render_result = strategy.render(
|
||||
round_id=round_obj.id,
|
||||
payload=round_obj.payload or {},
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
total = round_obj.payload.get("total", 20)
|
||||
keyboard = []
|
||||
row = []
|
||||
for i in range(total):
|
||||
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}"))
|
||||
if len(row) == 5:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
# For text input games, create pending attempt and set FSM state
|
||||
if render_result.requires_text_input:
|
||||
await _attempt_service.create_pending_attempt(db2, round_obj.id, db_user.id)
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
|
||||
|
||||
async def _render_server_lottery(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
flags = round_obj.payload.get("flags") or []
|
||||
shuffled_flags = flags.copy()
|
||||
random.shuffle(shuffled_flags)
|
||||
keyboard = []
|
||||
row = []
|
||||
for flag in shuffled_flags:
|
||||
row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{flag}"))
|
||||
if len(row) == 5:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
question = round_obj.payload.get("question", "")
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_emoji(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
question = round_obj.payload.get("question", "🤔")
|
||||
emoji_list = question.split()
|
||||
random.shuffle(emoji_list)
|
||||
shuffled_question = " ".join(emoji_list)
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=shuffled_question),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_anagram(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
letters = round_obj.payload.get("letters", "")
|
||||
await state.set_state(ContestStates.waiting_for_answer)
|
||||
await state.update_data(contest_round_id=round_obj.id)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(letters=letters),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
|
||||
texts = get_texts(db_user.language)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), callback_data=f"contest_pick_{round_obj.id}_blitz")]
|
||||
]
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
await callback.message.edit_text(
|
||||
render_result.text,
|
||||
reply_markup=render_result.keyboard,
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
|
||||
"""Handle button pick in contest games."""
|
||||
texts = get_texts(db_user.language)
|
||||
parts = callback.data.split("_")
|
||||
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "pick":
|
||||
|
||||
# Rate limit check
|
||||
if not _check_rate_limit(db_user.id, "contest_pick", limit=1, window_seconds=3):
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Validate callback data
|
||||
parts = _validate_callback_data(callback.data)
|
||||
if not parts or len(parts) < 4 or parts[1] != "pick":
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
round_id_str = parts[2]
|
||||
pick = "_".join(parts[3:])
|
||||
|
||||
try:
|
||||
round_id = int(round_id_str)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректные данные", show_alert=True)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
if not round_obj:
|
||||
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), show_alert=True)
|
||||
return
|
||||
|
||||
tpl = round_obj.template
|
||||
attempt = await get_attempt(db2, round_id, db_user.id)
|
||||
if attempt:
|
||||
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), show_alert=True)
|
||||
return
|
||||
|
||||
secret_idx = round_obj.payload.get("secret_idx")
|
||||
correct_flag = ""
|
||||
if tpl.slug == GAME_SERVER:
|
||||
flags = round_obj.payload.get("flags") or []
|
||||
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
|
||||
|
||||
is_winner = False
|
||||
if tpl.slug == GAME_SERVER:
|
||||
is_winner = pick == correct_flag
|
||||
elif tpl.slug == GAME_QUEST:
|
||||
# Format: quest_{idx}
|
||||
try:
|
||||
if pick.startswith("quest_"):
|
||||
idx = int(pick.split("_")[1])
|
||||
is_winner = secret_idx is not None and idx == secret_idx
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
elif tpl.slug == GAME_LOCKS:
|
||||
# Format: locks_{idx}
|
||||
try:
|
||||
if pick.startswith("locks_"):
|
||||
idx = int(pick.split("_")[1])
|
||||
is_winner = secret_idx is not None and idx == secret_idx
|
||||
except (ValueError, IndexError):
|
||||
is_winner = False
|
||||
elif tpl.slug == GAME_BLITZ:
|
||||
is_winner = pick == "blitz"
|
||||
else:
|
||||
is_winner = False
|
||||
|
||||
# Check if max winners already reached
|
||||
if is_winner and round_obj.winners_count >= round_obj.max_winners:
|
||||
is_winner = False # Too late, max winners already reached
|
||||
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db2, round_obj)
|
||||
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
|
||||
await callback.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), show_alert=True)
|
||||
else:
|
||||
responses = {
|
||||
GAME_QUEST: ["Пусто", "Ложный сервер", "Найди другой узел"],
|
||||
GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"],
|
||||
GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"],
|
||||
}.get(tpl.slug, ["Неудача"])
|
||||
await callback.answer(random.choice(responses), show_alert=True)
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
data = await state.get_data()
|
||||
round_id = data.get("contest_round_id")
|
||||
if not round_id:
|
||||
# Re-check subscription
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
if not _user_allowed(subscription):
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_NOT_ELIGIBLE", "Игра недоступна без активной подписки."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
|
||||
if not round_obj:
|
||||
await message.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await callback.answer(
|
||||
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Process attempt using service
|
||||
result = await _attempt_service.process_button_attempt(
|
||||
db=db2,
|
||||
round_obj=round_obj,
|
||||
user_id=db_user.id,
|
||||
pick=pick,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
await callback.answer(result.message, show_alert=True)
|
||||
|
||||
|
||||
@auth_required
|
||||
@error_handler
|
||||
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
|
||||
"""Handle text answer in contest games."""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
data = await state.get_data()
|
||||
round_id = data.get("contest_round_id")
|
||||
if not round_id:
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db2:
|
||||
active_rounds = await get_active_rounds(db2)
|
||||
round_obj = next((r for r in active_rounds if r.id == round_id), None)
|
||||
|
||||
if not round_obj:
|
||||
await message.answer(
|
||||
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
attempt = await get_attempt(db2, round_obj.id, db_user.id)
|
||||
if attempt:
|
||||
await message.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
return
|
||||
# Process attempt using service
|
||||
text_answer = (message.text or "").strip()
|
||||
result = await _attempt_service.process_text_attempt(
|
||||
db=db2,
|
||||
round_obj=round_obj,
|
||||
user_id=db_user.id,
|
||||
text_answer=text_answer,
|
||||
language=db_user.language,
|
||||
)
|
||||
|
||||
answer = (message.text or "").strip().upper()
|
||||
tpl = round_obj.template
|
||||
correct = (round_obj.payload.get("answer") or "").upper()
|
||||
await message.answer(
|
||||
result.message,
|
||||
reply_markup=get_back_keyboard(db_user.language),
|
||||
)
|
||||
|
||||
is_winner = correct and answer == correct
|
||||
|
||||
# Check if max winners already reached
|
||||
if is_winner and round_obj.winners_count >= round_obj.max_winners:
|
||||
is_winner = False # Too late, max winners already reached
|
||||
|
||||
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner)
|
||||
|
||||
if is_winner:
|
||||
await increment_winner_count(db2, round_obj)
|
||||
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
|
||||
await message.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), reply_markup=get_back_keyboard(db_user.language))
|
||||
else:
|
||||
await message.answer(texts.t("CONTEST_LOSE", "Не верно, попробуй снова в следующем раунде."), reply_markup=get_back_keyboard(db_user.language))
|
||||
await state.clear()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
"""Register contest handlers."""
|
||||
dp.callback_query.register(show_contests_menu, F.data == "contests_menu")
|
||||
dp.callback_query.register(play_contest, F.data.startswith("contest_play_"))
|
||||
dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_"))
|
||||
|
||||
+134
-42
@@ -40,7 +40,8 @@ from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.public_offer_service import PublicOfferService
|
||||
from app.services.faq_service import FaqService
|
||||
from app.utils.timezone import format_local_datetime
|
||||
from app.handlers.subscription.traffic import handle_add_traffic
|
||||
from app.utils.pricing_utils import format_period_description
|
||||
from app.handlers.subscription.traffic import handle_add_traffic, add_traffic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1237,39 +1238,68 @@ async def handle_activate_button(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""
|
||||
Умная кнопка активации — система сама решает что делать:
|
||||
- Если подписка активна — ничего не делать
|
||||
- Если подписка истекла — продлить с теми же параметрами
|
||||
- Если подписки нет — создать новую с дефолтными параметрами
|
||||
Выбирает максимальный период, который можно оплатить из баланса.
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Получить подписку пользователя
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import TransactionType, PaymentMethod
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.subscription_renewal_service import SubscriptionRenewalService
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
|
||||
# Если подписка активна — ничего не делаем
|
||||
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
|
||||
await callback.answer(
|
||||
texts.t("SUBSCRIPTION_ALREADY_ACTIVE", "✅ Подписка уже активна!"),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Параметры из подписки или дефолтные
|
||||
device_limit = subscription.device_limit if subscription else settings.DEFAULT_DEVICE_LIMIT
|
||||
traffic_limit_gb = subscription.traffic_limit_gb if subscription else 0
|
||||
connected_squads = subscription.connected_squads if subscription else []
|
||||
|
||||
# Получить IDs серверов из UUIDs
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
|
||||
# Определяем параметры подписки
|
||||
if subscription:
|
||||
# Есть подписка (возможно истекшая) — берём её параметры
|
||||
device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||||
connected_squads = subscription.connected_squads or []
|
||||
else:
|
||||
# Нет подписки — дефолтные параметры
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
traffic_limit_gb = 0
|
||||
connected_squads = []
|
||||
|
||||
# Если серверы не выбраны — берём бесплатные по умолчанию
|
||||
if not connected_squads:
|
||||
available_servers = await get_available_server_squads(db, promo_group_id=db_user.promo_group_id)
|
||||
connected_squads = [
|
||||
s.squad_uuid for s in available_servers
|
||||
if s.is_available and s.price_kopeks == 0
|
||||
]
|
||||
# Если бесплатных нет — берём первый доступный
|
||||
if not connected_squads and available_servers:
|
||||
connected_squads = [available_servers[0].squad_uuid]
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
|
||||
|
||||
|
||||
balance = db_user.balance_kopeks
|
||||
available_periods = [int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS]
|
||||
|
||||
available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# Найти максимальный период <= баланса
|
||||
best_period = None
|
||||
best_price = 0
|
||||
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
# Найти максимальный период, цена которого <= баланса
|
||||
for period in sorted(available_periods, reverse=True):
|
||||
|
||||
for period in available_periods:
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period,
|
||||
traffic_limit_gb,
|
||||
@@ -1282,31 +1312,88 @@ async def handle_activate_button(
|
||||
best_period = period
|
||||
best_price = price
|
||||
break
|
||||
|
||||
if best_period:
|
||||
# Создать новую подписку
|
||||
from app.database.crud.subscription import create_paid_subscription
|
||||
new_subscription = await create_paid_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
best_period,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=True
|
||||
|
||||
if not best_period:
|
||||
# Показать сколько не хватает для минимального периода
|
||||
min_period = min(available_periods) if available_periods else 30
|
||||
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
)
|
||||
|
||||
# Списать деньги
|
||||
db_user.balance_kopeks -= best_price
|
||||
await db.commit()
|
||||
|
||||
missing = min_price - balance
|
||||
await callback.answer(
|
||||
texts.t("ACTIVATION_SUCCESS", f"✅ Подписка активирована на {best_period} дней за {best_price//100} руб!"),
|
||||
texts.t(
|
||||
"INSUFFICIENT_FUNDS_DETAILED",
|
||||
f"❌ Недостаточно средств. Не хватает {missing // 100} ₽"
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
try:
|
||||
if subscription:
|
||||
# Продление существующей подписки
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
pricing = await renewal_service.calculate_pricing(
|
||||
db, db_user, subscription, best_period
|
||||
)
|
||||
|
||||
result = await renewal_service.finalize(
|
||||
db, db_user, subscription,
|
||||
pricing,
|
||||
description=f"Автоматическое продление на {best_period} дней",
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ACTIVATION_SUCCESS",
|
||||
f"✅ Подписка продлена на {best_period} дней за {best_price // 100} ₽!"
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
else:
|
||||
# Создание новой подписки
|
||||
new_subscription = await create_paid_subscription(
|
||||
db,
|
||||
db_user.id,
|
||||
best_period,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
connected_squads=connected_squads,
|
||||
update_server_counters=True
|
||||
)
|
||||
|
||||
# Списать баланс правильно
|
||||
await subtract_user_balance(
|
||||
db, db_user, best_price,
|
||||
f"Активация подписки на {best_period} дней"
|
||||
)
|
||||
|
||||
# Создать пользователя в RemnaWave
|
||||
await subscription_service.create_remnawave_user(db, new_subscription)
|
||||
|
||||
# Создать транзакцию
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=best_price,
|
||||
description=f"Активация подписки на {best_period} дней",
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"ACTIVATION_SUCCESS",
|
||||
f"✅ Подписка активирована на {best_period} дней за {best_price // 100} ₽!"
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка автоматической активации для {db_user.telegram_id}: {e}")
|
||||
await callback.answer(
|
||||
texts.t("INSUFFICIENT_FUNDS", "❌ Недостаточно средств для активации подписки"),
|
||||
texts.t("ACTIVATION_ERROR", "❌ Ошибка активации. Попробуйте позже."),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
@@ -1384,6 +1471,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "buy_traffic"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
add_traffic,
|
||||
F.data.startswith("add_traffic_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_activate_button,
|
||||
F.data == "activate_button"
|
||||
|
||||
@@ -481,9 +481,11 @@ def register_handlers(dp: Dispatcher):
|
||||
F.data == "referral_analytics"
|
||||
)
|
||||
|
||||
async def handle_referral_list_page(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
page = int(callback.data.split('_')[-1])
|
||||
await show_detailed_referral_list(callback, db_user, db, page)
|
||||
|
||||
dp.callback_query.register(
|
||||
lambda callback, db_user, db: show_detailed_referral_list(
|
||||
callback, db_user, db, int(callback.data.split('_')[-1])
|
||||
),
|
||||
handle_referral_list_page,
|
||||
F.data.startswith("referral_list_page_")
|
||||
)
|
||||
|
||||
@@ -39,6 +39,23 @@ async def start_simple_subscription_purchase(
|
||||
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
|
||||
return
|
||||
|
||||
# Проверка ограничения на покупку/продление подписки
|
||||
if getattr(db_user, 'restriction_subscription', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
|
||||
support_url = settings.get_support_contact_url()
|
||||
keyboard = []
|
||||
if support_url:
|
||||
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
|
||||
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription")])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🚫 <b>Покупка подписки ограничена</b>\n\n{reason}\n\n"
|
||||
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
# Проверяем, есть ли у пользователя подписка
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
current_subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
|
||||
+37
-1
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
@@ -18,7 +19,7 @@ from app.database.crud.campaign import (
|
||||
get_campaign_by_start_parameter,
|
||||
get_campaign_by_id,
|
||||
)
|
||||
from app.database.models import UserStatus, SubscriptionStatus
|
||||
from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus
|
||||
from app.keyboards.inline import (
|
||||
get_rules_keyboard,
|
||||
get_privacy_policy_keyboard,
|
||||
@@ -36,6 +37,10 @@ from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||||
from app.services.pinned_message_service import (
|
||||
deliver_pinned_message_to_user,
|
||||
get_active_pinned_message,
|
||||
)
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
@@ -61,6 +66,22 @@ def _calculate_subscription_flags(subscription):
|
||||
return has_active_subscription, subscription_is_active
|
||||
|
||||
|
||||
async def _send_pinned_message(
|
||||
bot: Bot,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
pinned_message: Optional[PinnedMessage] = None,
|
||||
) -> None:
|
||||
try:
|
||||
await deliver_pinned_message_to_user(bot, db, user, pinned_message)
|
||||
except Exception as error: # noqa: BLE001
|
||||
logger.error(
|
||||
"Не удалось отправить закрепленное сообщение пользователю %s: %s",
|
||||
getattr(user, "telegram_id", "unknown"),
|
||||
error,
|
||||
)
|
||||
|
||||
|
||||
async def _apply_campaign_bonus_if_needed(
|
||||
db: AsyncSession,
|
||||
user,
|
||||
@@ -404,6 +425,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
user.subscription
|
||||
)
|
||||
|
||||
pinned_message = await get_active_pinned_message(db)
|
||||
|
||||
if pinned_message and pinned_message.send_before_menu:
|
||||
await _send_pinned_message(message.bot, db, user, pinned_message)
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
@@ -438,6 +464,9 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
if pinned_message and not pinned_message.send_before_menu:
|
||||
await _send_pinned_message(message.bot, db, user, pinned_message)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
@@ -1094,6 +1123,7 @@ async def complete_registration_from_callback(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await _send_pinned_message(callback.bot, db, existing_user)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await callback.message.answer(
|
||||
@@ -1232,6 +1262,7 @@ async def complete_registration_from_callback(
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
)
|
||||
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
|
||||
await _send_pinned_message(callback.bot, db, user)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
|
||||
else:
|
||||
@@ -1277,6 +1308,7 @@ async def complete_registration_from_callback(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await _send_pinned_message(callback.bot, db, user)
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню: {e}")
|
||||
@@ -1374,6 +1406,7 @@ async def complete_registration(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await _send_pinned_message(message.bot, db, existing_user)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
|
||||
await message.answer(
|
||||
@@ -1535,6 +1568,7 @@ async def complete_registration(
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
)
|
||||
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
|
||||
else:
|
||||
@@ -1581,6 +1615,7 @@ async def complete_registration(
|
||||
parse_mode="HTML"
|
||||
)
|
||||
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при показе главного меню: {e}")
|
||||
await message.answer(
|
||||
@@ -1925,6 +1960,7 @@ async def required_sub_channel_check(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await _send_pinned_message(bot, db, user)
|
||||
else:
|
||||
from app.keyboards.inline import get_rules_keyboard
|
||||
|
||||
|
||||
@@ -120,6 +120,14 @@ from .traffic import (
|
||||
select_traffic,
|
||||
)
|
||||
|
||||
from .modem import (
|
||||
handle_modem_menu,
|
||||
handle_modem_enable,
|
||||
handle_modem_confirm,
|
||||
handle_modem_disable,
|
||||
register_modem_handlers,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'activate_trial',
|
||||
'add_traffic',
|
||||
@@ -185,6 +193,11 @@ __all__ = [
|
||||
'handle_subscription_config_back',
|
||||
'handle_subscription_settings',
|
||||
'handle_switch_traffic',
|
||||
'handle_modem_menu',
|
||||
'handle_modem_enable',
|
||||
'handle_modem_confirm',
|
||||
'handle_modem_disable',
|
||||
'register_modem_handlers',
|
||||
'load_app_config',
|
||||
'refresh_traffic_config',
|
||||
'register_handlers',
|
||||
|
||||
@@ -79,7 +79,12 @@ from app.utils.promo_offer import (
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
from .countries import _get_available_countries, _should_show_countries_management
|
||||
from .countries import (
|
||||
_build_countries_selection_text,
|
||||
_get_available_countries,
|
||||
_get_preselected_free_countries,
|
||||
_should_show_countries_management,
|
||||
)
|
||||
from .pricing import _build_subscription_period_prompt
|
||||
|
||||
async def handle_autopay_menu(
|
||||
@@ -260,9 +265,18 @@ async def _show_previous_configuration_step(
|
||||
data = await state.get_data()
|
||||
selected_countries = data.get('countries', [])
|
||||
|
||||
# Если страны не выбраны — автоматически предвыбираем бесплатные
|
||||
if not selected_countries:
|
||||
selected_countries = _get_preselected_free_countries(countries)
|
||||
data['countries'] = selected_countries
|
||||
await state.set_data(data)
|
||||
|
||||
# Формируем текст с описаниями сквадов
|
||||
selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
|
||||
selection_text,
|
||||
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
return
|
||||
|
||||
@@ -390,9 +390,15 @@ def get_traffic_switch_keyboard(
|
||||
language: str = "ru",
|
||||
subscription_end_date: datetime = None,
|
||||
discount_percent: int = 0,
|
||||
base_traffic_gb: int = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
# Если базовый трафик не передан, используем текущий
|
||||
# (для обратной совместимости и случаев без докупленного трафика)
|
||||
if base_traffic_gb is None:
|
||||
base_traffic_gb = current_traffic_gb
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ""
|
||||
if subscription_end_date:
|
||||
@@ -403,7 +409,8 @@ def get_traffic_switch_keyboard(
|
||||
packages = settings.get_traffic_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
||||
|
||||
current_price_per_month = settings.get_traffic_price(current_traffic_gb)
|
||||
# Используем базовый трафик для определения цены текущего пакета
|
||||
current_price_per_month = settings.get_traffic_price(base_traffic_gb)
|
||||
discounted_current_per_month, _ = apply_percentage_discount(
|
||||
current_price_per_month,
|
||||
discount_percent,
|
||||
@@ -422,7 +429,8 @@ def get_traffic_switch_keyboard(
|
||||
price_diff_per_month = discounted_price_per_month - discounted_current_per_month
|
||||
total_price_diff = price_diff_per_month * months_multiplier
|
||||
|
||||
if gb == current_traffic_gb:
|
||||
# Сравниваем с базовым трафиком (без докупленного)
|
||||
if gb == base_traffic_gb:
|
||||
emoji = "✅"
|
||||
action_text = " (текущий)"
|
||||
price_text = ""
|
||||
|
||||
@@ -646,7 +646,8 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
|
||||
"name": server.display_name,
|
||||
"price_kopeks": server.price_kopeks,
|
||||
"country_code": server.country_code,
|
||||
"is_available": server.is_available and not server.is_full
|
||||
"is_available": server.is_available and not server.is_full,
|
||||
"description": server.description or ""
|
||||
})
|
||||
|
||||
if not countries:
|
||||
@@ -675,7 +676,8 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
|
||||
"uuid": squad["uuid"],
|
||||
"name": squad_name,
|
||||
"price_kopeks": 0,
|
||||
"is_available": True
|
||||
"is_available": True,
|
||||
"description": ""
|
||||
})
|
||||
|
||||
await cache.set(cache_key_value, countries, 300)
|
||||
@@ -684,7 +686,7 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения списка стран: {e}")
|
||||
fallback_countries = [
|
||||
{"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True},
|
||||
{"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True, "description": ""},
|
||||
]
|
||||
|
||||
await cache.set(cache_key_value, fallback_countries, 60)
|
||||
@@ -694,6 +696,36 @@ async def _get_countries_info(squad_uuids):
|
||||
countries = await _get_available_countries()
|
||||
return [c for c in countries if c['uuid'] in squad_uuids]
|
||||
|
||||
|
||||
def _get_preselected_free_countries(countries: List[dict]) -> List[str]:
|
||||
"""Получить UUID бесплатных серверов для автоматического предвыбора."""
|
||||
return [
|
||||
c['uuid'] for c in countries
|
||||
if c.get('is_available', True) and c.get('price_kopeks', 0) == 0
|
||||
]
|
||||
|
||||
|
||||
def _build_countries_selection_text(countries: List[dict], base_text: str) -> str:
|
||||
"""
|
||||
Формирует текст выбора серверов с описаниями.
|
||||
|
||||
Если у серверов есть description — добавляет их под базовым текстом.
|
||||
"""
|
||||
descriptions = []
|
||||
for country in countries:
|
||||
if not country.get('is_available', True):
|
||||
continue
|
||||
desc = country.get('description', '').strip()
|
||||
if desc:
|
||||
name = country.get('name', '')
|
||||
descriptions.append(f"<b>{name}</b>\n{desc}")
|
||||
|
||||
if not descriptions:
|
||||
return base_text
|
||||
|
||||
return f"{base_text}\n\n" + "\n\n".join(descriptions)
|
||||
|
||||
|
||||
async def handle_add_country_to_subscription(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
|
||||
@@ -355,6 +355,28 @@ async def confirm_change_devices(
|
||||
).format(count=new_devices_count)
|
||||
cost_text = texts.t("DEVICE_CHANGE_NO_REFUND", "Возврат средств не производится")
|
||||
|
||||
# Проверяем количество подключённых устройств для предупреждения
|
||||
devices_warning = ""
|
||||
if new_devices_count < current_devices and db_user.remnawave_uuid:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
|
||||
if response and 'response' in response:
|
||||
connected_count = response['response'].get('total', 0)
|
||||
if connected_count > new_devices_count:
|
||||
devices_warning = texts.t(
|
||||
"DEVICE_CHANGE_RESET_WARNING",
|
||||
(
|
||||
"\n⚠️ <b>Внимание!</b>\n"
|
||||
"У вас подключено {connected} устройств.\n"
|
||||
"При уменьшении лимита до {new} все устройства будут сброшены.\n"
|
||||
"Вам нужно будет заново подключить нужные устройства.\n"
|
||||
),
|
||||
).format(connected=connected_count, new=new_devices_count)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка проверки устройств: {e}")
|
||||
|
||||
confirm_text = texts.t(
|
||||
"DEVICE_CHANGE_CONFIRMATION",
|
||||
(
|
||||
@@ -372,6 +394,9 @@ async def confirm_change_devices(
|
||||
cost=cost_text,
|
||||
)
|
||||
|
||||
if devices_warning:
|
||||
confirm_text += devices_warning
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text,
|
||||
reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language),
|
||||
@@ -431,6 +456,28 @@ async def execute_change_devices(
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# При уменьшении лимита - сбросить лишние устройства
|
||||
devices_reset_count = 0
|
||||
if new_devices_count < current_devices and db_user.remnawave_uuid:
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
|
||||
if response and 'response' in response:
|
||||
devices_list = response['response'].get('devices', [])
|
||||
connected_count = len(devices_list)
|
||||
|
||||
# Если подключённых устройств больше чем новый лимит - сбросить все
|
||||
if connected_count > new_devices_count:
|
||||
logger.info(
|
||||
f"🔧 Сброс устройств при уменьшении лимита: "
|
||||
f"подключено {connected_count}, новый лимит {new_devices_count}"
|
||||
)
|
||||
await api.reset_user_devices(db_user.remnawave_uuid)
|
||||
devices_reset_count = connected_count
|
||||
except Exception as reset_error:
|
||||
logger.error(f"Ошибка сброса устройств при уменьшении лимита: {reset_error}")
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -466,6 +513,12 @@ async def execute_change_devices(
|
||||
"DEVICE_CHANGE_RESULT_LINE",
|
||||
"📱 Было: {old} → Стало: {new}\n",
|
||||
).format(old=current_devices, new=new_devices_count)
|
||||
if devices_reset_count > 0:
|
||||
success_text += texts.t(
|
||||
"DEVICE_CHANGE_DEVICES_RESET",
|
||||
"\n🔄 Сброшено устройств: {count}\n"
|
||||
"💡 Подключите заново нужные устройства (до {limit} шт.)\n\n",
|
||||
).format(count=devices_reset_count, limit=new_devices_count)
|
||||
success_text += texts.t(
|
||||
"DEVICE_CHANGE_NO_REFUND_INFO",
|
||||
"ℹ️ Возврат средств не производится",
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
Хендлеры для управления модемом в подписке.
|
||||
|
||||
Модем - это дополнительное устройство, которое можно подключить к подписке
|
||||
за отдельную плату. При подключении увеличивается лимит устройств.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from aiogram import Dispatcher, types, F
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_back_keyboard, get_insufficient_balance_keyboard
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.modem_service import (
|
||||
get_modem_service,
|
||||
ModemError,
|
||||
MODEM_WARNING_DAYS_CRITICAL,
|
||||
MODEM_WARNING_DAYS_INFO,
|
||||
)
|
||||
from app.utils.decorators import error_handler, modem_available
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_modem_keyboard(language: str, modem_enabled: bool):
|
||||
"""Клавиатура управления модемом."""
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if modem_enabled:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("MODEM_DISABLE_BUTTON", "Отключить модем"),
|
||||
callback_data="modem_disable"
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("MODEM_ENABLE_BUTTON", "Подключить модем"),
|
||||
callback_data="modem_enable"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="subscription_settings"
|
||||
)
|
||||
])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_modem_confirm_keyboard(language: str):
|
||||
"""Клавиатура подтверждения подключения модема."""
|
||||
texts = get_texts(language)
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("MODEM_CONFIRM_BUTTON", "Подтвердить подключение"),
|
||||
callback_data="modem_confirm"
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.CANCEL,
|
||||
callback_data="subscription_modem"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available()
|
||||
async def handle_modem_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Показывает меню управления модемом."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
modem_enabled = service.get_modem_enabled(subscription)
|
||||
modem_price = settings.get_modem_price_per_month()
|
||||
|
||||
if modem_enabled:
|
||||
status_text = texts.t("MODEM_STATUS_ENABLED", "Подключен")
|
||||
info_text = texts.t(
|
||||
"MODEM_INFO_ENABLED",
|
||||
(
|
||||
"<b>Модем</b>\n\n"
|
||||
"Статус: {status}\n\n"
|
||||
"Модем подключен к вашей подписке.\n"
|
||||
"Ежемесячная плата: {price}\n\n"
|
||||
"При отключении модема возврат средств не производится."
|
||||
),
|
||||
).format(
|
||||
status=status_text,
|
||||
price=texts.format_price(modem_price),
|
||||
)
|
||||
else:
|
||||
status_text = texts.t("MODEM_STATUS_DISABLED", "Не подключен")
|
||||
info_text = texts.t(
|
||||
"MODEM_INFO_DISABLED",
|
||||
(
|
||||
"<b>Модем</b>\n\n"
|
||||
"Статус: {status}\n\n"
|
||||
"Подключите модем к вашей подписке.\n"
|
||||
"Ежемесячная плата: {price}\n\n"
|
||||
"При подключении модема будет добавлено дополнительное устройство."
|
||||
),
|
||||
).format(
|
||||
status=status_text,
|
||||
price=texts.format_price(modem_price),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
info_text,
|
||||
reply_markup=get_modem_keyboard(db_user.language, modem_enabled),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_enable(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Обработчик подключения модема - показывает информацию о цене."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
price_info = service.calculate_price(subscription)
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
|
||||
has_funds, missing_kopeks = service.check_balance(db_user, price_info.final_price)
|
||||
|
||||
if not has_funds:
|
||||
if price_info.has_discount:
|
||||
required_text = (
|
||||
f"{texts.format_price(price_info.final_price)} "
|
||||
f"(за {price_info.charged_months} мес, скидка {price_info.discount_percent}%)"
|
||||
)
|
||||
else:
|
||||
required_text = (
|
||||
f"{texts.format_price(price_info.final_price)} "
|
||||
f"(за {price_info.charged_months} мес)"
|
||||
)
|
||||
|
||||
message_text = texts.t(
|
||||
"MODEM_INSUFFICIENT_FUNDS",
|
||||
(
|
||||
"<b>Недостаточно средств</b>\n\n"
|
||||
"Стоимость подключения модема: {required}\n"
|
||||
"На балансе: {balance}\n"
|
||||
"Не хватает: {missing}\n\n"
|
||||
"Выберите способ пополнения."
|
||||
),
|
||||
).format(
|
||||
required=required_text,
|
||||
balance=texts.format_price(db_user.balance_kopeks),
|
||||
missing=texts.format_price(missing_kopeks),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_insufficient_balance_keyboard(
|
||||
db_user.language,
|
||||
amount_kopeks=missing_kopeks,
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
warning_level = service.get_period_warning_level(price_info.remaining_days)
|
||||
|
||||
if warning_level == "critical":
|
||||
warning_text = texts.t(
|
||||
"MODEM_SHORT_PERIOD_WARNING",
|
||||
"\n<b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\n"
|
||||
"После продления подписки модем нужно будет оплатить заново!"
|
||||
).format(days=price_info.remaining_days)
|
||||
elif warning_level == "info":
|
||||
warning_text = texts.t(
|
||||
"MODEM_PERIOD_NOTE",
|
||||
"\nДо окончания подписки: <b>{days} дн.</b>\n"
|
||||
"После продления модем нужно будет оплатить заново."
|
||||
).format(days=price_info.remaining_days)
|
||||
else:
|
||||
warning_text = ""
|
||||
|
||||
if price_info.has_discount:
|
||||
price_text = texts.t(
|
||||
"MODEM_PRICE_WITH_DISCOUNT",
|
||||
"Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n"
|
||||
"Скидка {discount}%: -{discount_amount}"
|
||||
).format(
|
||||
base_price=texts.format_price(price_info.base_price),
|
||||
final_price=texts.format_price(price_info.final_price),
|
||||
months=price_info.charged_months,
|
||||
discount=price_info.discount_percent,
|
||||
discount_amount=texts.format_price(price_info.discount_amount),
|
||||
)
|
||||
else:
|
||||
price_text = texts.t(
|
||||
"MODEM_PRICE_NO_DISCOUNT",
|
||||
"Стоимость: {price} (за {months} мес)"
|
||||
).format(
|
||||
price=texts.format_price(price_info.final_price),
|
||||
months=price_info.charged_months,
|
||||
)
|
||||
|
||||
confirm_text = texts.t(
|
||||
"MODEM_CONFIRM_ENABLE_BASE",
|
||||
(
|
||||
"<b>Подтверждение подключения модема</b>\n\n"
|
||||
"{price_text}\n\n"
|
||||
"При подключении модема:\n"
|
||||
"К подписке добавится дополнительное устройство\n"
|
||||
"Ежемесячная плата увеличится на {monthly_price}\n\n"
|
||||
"Подтвердить подключение?"
|
||||
),
|
||||
).format(
|
||||
price_text=price_text,
|
||||
monthly_price=texts.format_price(modem_price_per_month),
|
||||
)
|
||||
|
||||
end_date_str = price_info.end_date.strftime("%d.%m.%Y")
|
||||
period_info = texts.t(
|
||||
"MODEM_PERIOD_INFO",
|
||||
"\nМодем действует до: <b>{end_date}</b> ({days} дн.)"
|
||||
).format(end_date=end_date_str, days=price_info.remaining_days)
|
||||
|
||||
confirm_text += period_info + warning_text
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text,
|
||||
reply_markup=get_modem_confirm_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_enable=True)
|
||||
async def handle_modem_confirm(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Подтверждение и активация модема."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
result = await service.enable_modem(db, db_user, subscription)
|
||||
|
||||
if not result.success:
|
||||
error_messages = {
|
||||
ModemError.INSUFFICIENT_FUNDS: texts.t(
|
||||
"MODEM_INSUFFICIENT_FUNDS_SHORT",
|
||||
"Недостаточно средств на балансе"
|
||||
),
|
||||
ModemError.CHARGE_ERROR: texts.t(
|
||||
"PAYMENT_CHARGE_ERROR",
|
||||
"Ошибка списания средств"
|
||||
),
|
||||
ModemError.UPDATE_ERROR: texts.ERROR,
|
||||
}
|
||||
|
||||
error_text = error_messages.get(result.error, texts.ERROR)
|
||||
|
||||
if result.error == ModemError.INSUFFICIENT_FUNDS:
|
||||
await callback.message.edit_text(
|
||||
error_text,
|
||||
reply_markup=get_back_keyboard(db_user.language, "modem_enable"),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
else:
|
||||
await callback.answer(error_text, show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "modem", False, True, result.charged_amount
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о подключении модема: {e}")
|
||||
|
||||
success_text = texts.t(
|
||||
"MODEM_ENABLED_SUCCESS",
|
||||
(
|
||||
"<b>Модем успешно подключен!</b>\n\n"
|
||||
"Модем активирован\n"
|
||||
"Добавлено устройство для модема\n"
|
||||
),
|
||||
)
|
||||
if result.charged_amount > 0:
|
||||
success_text += texts.t(
|
||||
"MODEM_CHARGED",
|
||||
"Списано: {amount}",
|
||||
).format(amount=texts.format_price(result.charged_amount))
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language, "subscription_settings"),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@error_handler
|
||||
@modem_available(for_disable=True)
|
||||
async def handle_modem_disable(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Отключение модема."""
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
service = get_modem_service()
|
||||
|
||||
result = await service.disable_modem(db, db_user, subscription)
|
||||
|
||||
if not result.success:
|
||||
await callback.answer(texts.ERROR, show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_update_notification(
|
||||
db, db_user, subscription, "modem", True, False, 0
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления об отключении модема: {e}")
|
||||
|
||||
success_text = texts.t(
|
||||
"MODEM_DISABLED_SUCCESS",
|
||||
(
|
||||
"<b>Модем отключен</b>\n\n"
|
||||
"Модем деактивирован\n"
|
||||
"Возврат средств не производится"
|
||||
),
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_text,
|
||||
reply_markup=get_back_keyboard(db_user.language, "subscription_settings"),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_modem_handlers(dp: Dispatcher):
|
||||
"""Регистрация обработчиков модема."""
|
||||
dp.callback_query.register(
|
||||
handle_modem_menu,
|
||||
F.data == "subscription_modem"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_modem_enable,
|
||||
F.data == "modem_enable"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_modem_confirm,
|
||||
F.data == "modem_confirm"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_modem_disable,
|
||||
F.data == "modem_disable"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,12 @@ from app.utils.promo_offer import (
|
||||
)
|
||||
|
||||
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
|
||||
from .countries import _get_available_countries, _should_show_countries_management
|
||||
from .countries import (
|
||||
_build_countries_selection_text,
|
||||
_get_available_countries,
|
||||
_get_preselected_free_countries,
|
||||
_should_show_countries_management,
|
||||
)
|
||||
from .summary import present_subscription_summary
|
||||
|
||||
async def handle_add_traffic(
|
||||
@@ -91,7 +96,18 @@ async def handle_add_traffic(
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
# Проверяем, включена ли функция докупки трафика
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TRAFFIC_TOPUP_DISABLED",
|
||||
"⚠️ Функция докупки трафика отключена",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if settings.is_traffic_topup_blocked():
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
"TRAFFIC_FIXED_MODE",
|
||||
@@ -146,6 +162,43 @@ async def handle_add_traffic(
|
||||
|
||||
await callback.answer()
|
||||
|
||||
def _calculate_traffic_reset_price(subscription) -> int:
|
||||
"""Рассчитывает цену сброса трафика в зависимости от настроек."""
|
||||
mode = settings.get_traffic_reset_price_mode()
|
||||
base_price = settings.get_traffic_reset_base_price()
|
||||
|
||||
# Если базовая цена не задана, используем цену периода 30 дней
|
||||
if base_price == 0:
|
||||
base_price = PERIOD_PRICES.get(30, 0)
|
||||
|
||||
if mode == "period":
|
||||
# Старое поведение: фиксированная цена = стоимость периода
|
||||
return base_price
|
||||
|
||||
elif mode == "traffic":
|
||||
# Цена = стоимость текущего пакета трафика
|
||||
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
return max(traffic_price, base_price)
|
||||
|
||||
elif mode == "traffic_with_purchased":
|
||||
# Цена = стоимость базового трафика + докупленного
|
||||
# Базовый трафик = текущий лимит - докупленный
|
||||
purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
|
||||
|
||||
# Получаем цену базового трафика
|
||||
base_traffic_price = settings.get_traffic_price(base_traffic_gb) if base_traffic_gb > 0 else 0
|
||||
|
||||
# Получаем цену докупленного трафика
|
||||
purchased_traffic_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0
|
||||
|
||||
total_price = base_traffic_price + purchased_traffic_price
|
||||
return max(total_price, base_price)
|
||||
|
||||
# Fallback на базовую цену
|
||||
return base_price
|
||||
|
||||
|
||||
async def handle_reset_traffic(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -153,7 +206,7 @@ async def handle_reset_traffic(
|
||||
):
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
if settings.is_traffic_topup_blocked():
|
||||
await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -168,19 +221,40 @@ async def handle_reset_traffic(
|
||||
await callback.answer("⌛ У вас безлимитный трафик", show_alert=True)
|
||||
return
|
||||
|
||||
reset_price = PERIOD_PRICES[30]
|
||||
reset_price = _calculate_traffic_reset_price(subscription)
|
||||
|
||||
if db_user.balance_kopeks < reset_price:
|
||||
await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
|
||||
return
|
||||
# Формируем информацию о расчете цены
|
||||
purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
price_info = ""
|
||||
if purchased_gb > 0 and settings.get_traffic_reset_price_mode() == "traffic_with_purchased":
|
||||
base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
|
||||
price_info = (
|
||||
f"\n\n💡 <i>Расчет цены:</i>\n"
|
||||
f"• Базовый трафик: {texts.format_traffic(base_traffic_gb)}\n"
|
||||
f"• Докупленный: {texts.format_traffic(purchased_gb)}"
|
||||
)
|
||||
|
||||
# Проверяем достаточно ли средств
|
||||
has_enough_balance = db_user.balance_kopeks >= reset_price
|
||||
missing_kopeks = max(0, reset_price - db_user.balance_kopeks)
|
||||
|
||||
# Формируем текст о балансе
|
||||
balance_info = f"\n\n💰 На балансе: {texts.format_price(db_user.balance_kopeks)}"
|
||||
if not has_enough_balance:
|
||||
balance_info += f"\n⚠️ Не хватает: {texts.format_price(missing_kopeks)}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🔄 <b>Сброс трафика</b>\n\n"
|
||||
f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n"
|
||||
f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n"
|
||||
f"Стоимость сброса: {texts.format_price(reset_price)}\n\n"
|
||||
f"Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n"
|
||||
"После сброса счетчик использованного трафика станет равным 0.",
|
||||
reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language)
|
||||
reply_markup=get_reset_traffic_confirm_keyboard(
|
||||
reset_price,
|
||||
db_user.language,
|
||||
has_enough_balance=has_enough_balance,
|
||||
missing_kopeks=missing_kopeks,
|
||||
)
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
@@ -192,14 +266,14 @@ async def confirm_reset_traffic(
|
||||
):
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
if settings.is_traffic_topup_blocked():
|
||||
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
reset_price = PERIOD_PRICES[30]
|
||||
reset_price = _calculate_traffic_reset_price(subscription)
|
||||
|
||||
if db_user.balance_kopeks < reset_price:
|
||||
missing_kopeks = reset_price - db_user.balance_kopeks
|
||||
@@ -348,9 +422,16 @@ async def select_traffic(
|
||||
|
||||
if await _should_show_countries_management(db_user):
|
||||
countries = await _get_available_countries(db_user.promo_group_id)
|
||||
# Автоматически предвыбираем бесплатные серверы
|
||||
preselected = _get_preselected_free_countries(countries)
|
||||
data['countries'] = preselected
|
||||
await state.set_data(data)
|
||||
# Формируем текст с описаниями сквадов
|
||||
selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
|
||||
await callback.message.edit_text(
|
||||
texts.SELECT_COUNTRIES,
|
||||
reply_markup=get_countries_keyboard(countries, [], db_user.language)
|
||||
selection_text,
|
||||
reply_markup=get_countries_keyboard(countries, preselected, db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_countries)
|
||||
await callback.answer()
|
||||
@@ -380,7 +461,7 @@ async def add_traffic(
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
if settings.is_traffic_fixed():
|
||||
if settings.is_traffic_topup_blocked():
|
||||
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -388,7 +469,7 @@ async def add_traffic(
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
base_price = settings.get_traffic_price(traffic_gb)
|
||||
base_price = settings.get_traffic_topup_price(traffic_gb)
|
||||
|
||||
if base_price == 0 and traffic_gb != 0:
|
||||
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
|
||||
@@ -458,8 +539,13 @@ async def add_traffic(
|
||||
|
||||
if traffic_gb == 0:
|
||||
subscription.traffic_limit_gb = 0
|
||||
# При переходе на безлимит сбрасываем докупленный трафик
|
||||
subscription.purchased_traffic_gb = 0
|
||||
else:
|
||||
await add_subscription_traffic(db, subscription, traffic_gb)
|
||||
# Записываем докупленный трафик для корректного расчета цены сброса
|
||||
current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
subscription.purchased_traffic_gb = current_purchased + traffic_gb
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
@@ -523,7 +609,7 @@ async def handle_switch_traffic(
|
||||
):
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
if settings.is_traffic_topup_blocked():
|
||||
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -535,6 +621,10 @@ async def handle_switch_traffic(
|
||||
return
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
# Вычисляем базовый трафик (без докупленного) для корректного расчёта цен
|
||||
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
base_traffic = current_traffic - purchased_traffic
|
||||
|
||||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||||
db_user,
|
||||
@@ -542,18 +632,25 @@ async def handle_switch_traffic(
|
||||
period_hint_days,
|
||||
)
|
||||
|
||||
# Показываем информацию о докупленном трафике, если он есть
|
||||
purchased_info = ""
|
||||
if purchased_traffic > 0:
|
||||
purchased_info = f"\n📦 Базовый пакет: {texts.format_traffic(base_traffic)}\n➕ Докуплено: {texts.format_traffic(purchased_traffic)}"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🔄 <b>Переключение лимита трафика</b>\n\n"
|
||||
f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
|
||||
f"Текущий лимит: {texts.format_traffic(current_traffic)}{purchased_info}\n"
|
||||
f"Выберите новый лимит трафика:\n\n"
|
||||
f"💡 <b>Важно:</b>\n"
|
||||
f"• При увеличении - доплата за разницу\n"
|
||||
f"• При уменьшении - возврат средств не производится",
|
||||
f"• При уменьшении - возврат средств не производится\n"
|
||||
f"• Докупленный трафик будет сброшен",
|
||||
reply_markup=get_traffic_switch_keyboard(
|
||||
current_traffic,
|
||||
db_user.language,
|
||||
subscription.end_date,
|
||||
traffic_discount_percent,
|
||||
base_traffic_gb=base_traffic,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -571,11 +668,16 @@ async def confirm_switch_traffic(
|
||||
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
|
||||
# Вычисляем базовый трафик (без докупленного) для корректного расчёта цены
|
||||
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
base_traffic = current_traffic - purchased_traffic
|
||||
|
||||
if new_traffic_gb == current_traffic:
|
||||
await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True)
|
||||
return
|
||||
|
||||
old_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
# Используем базовый трафик для определения текущей цены пакета
|
||||
old_price_per_month = settings.get_traffic_price(base_traffic)
|
||||
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
|
||||
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
@@ -692,6 +794,8 @@ async def execute_switch_traffic(
|
||||
)
|
||||
|
||||
subscription.traffic_limit_gb = new_traffic_gb
|
||||
# Сбрасываем докупленный трафик при переключении пакета
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
+101
-5
@@ -464,17 +464,61 @@ async def show_my_tickets_closed(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _split_long_block(block: str, max_len: int) -> list[str]:
|
||||
"""Разбивает слишком длинный блок на части."""
|
||||
if len(block) <= max_len:
|
||||
return [block]
|
||||
|
||||
parts = []
|
||||
remaining = block
|
||||
while remaining:
|
||||
if len(remaining) <= max_len:
|
||||
parts.append(remaining)
|
||||
break
|
||||
# Ищем место для разрыва (перенос строки или пробел)
|
||||
cut_at = max_len
|
||||
newline_pos = remaining.rfind('\n', 0, max_len)
|
||||
space_pos = remaining.rfind(' ', 0, max_len)
|
||||
|
||||
if newline_pos > max_len // 2:
|
||||
cut_at = newline_pos + 1
|
||||
elif space_pos > max_len // 2:
|
||||
cut_at = space_pos + 1
|
||||
|
||||
parts.append(remaining[:cut_at])
|
||||
remaining = remaining[cut_at:]
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int = 3500) -> list[str]:
|
||||
"""Разбивает текст на страницы с учётом лимита Telegram."""
|
||||
pages: list[str] = []
|
||||
current = header
|
||||
header_len = len(header)
|
||||
block_max_len = max_len - header_len - 50 # запас для безопасности
|
||||
|
||||
for block in message_blocks:
|
||||
if len(current) + len(block) > max_len:
|
||||
pages.append(current)
|
||||
# Если блок сам по себе слишком длинный — разбиваем его
|
||||
if len(block) > block_max_len:
|
||||
block_parts = _split_long_block(block, block_max_len)
|
||||
for part in block_parts:
|
||||
if len(current) + len(part) > max_len:
|
||||
if current.strip() and current != header:
|
||||
pages.append(current)
|
||||
current = header + part
|
||||
else:
|
||||
current += part
|
||||
elif len(current) + len(block) > max_len:
|
||||
if current.strip() and current != header:
|
||||
pages.append(current)
|
||||
current = header + block
|
||||
else:
|
||||
current += block
|
||||
|
||||
if current.strip():
|
||||
pages.append(current)
|
||||
|
||||
return pages if pages else [header]
|
||||
|
||||
|
||||
@@ -792,7 +836,7 @@ async def handle_ticket_reply(
|
||||
)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await message.answer(
|
||||
texts.t("TICKET_REPLY_SENT", "✅ Ваш ответ отправлен!"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
@@ -806,9 +850,13 @@ async def handle_ticket_reply(
|
||||
)]
|
||||
])
|
||||
)
|
||||
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
# Уведомить админов об ответе пользователя
|
||||
logger.info(f"Attempting to notify admins about ticket reply #{ticket_id}")
|
||||
await notify_admins_about_ticket_reply(ticket, reply_text, db)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding ticket reply: {e}")
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -971,6 +1019,54 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
logger.error(f"Error notifying admins about new ticket: {e}")
|
||||
|
||||
|
||||
async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db: AsyncSession):
|
||||
"""Уведомить админов об ответе пользователя на тикет"""
|
||||
logger.info(f"notify_admins_about_ticket_reply called for ticket #{ticket.id}")
|
||||
try:
|
||||
from app.config import settings
|
||||
if not settings.is_admin_notifications_enabled():
|
||||
logger.info(f"Admin notifications disabled. Reply to ticket #{ticket.id}")
|
||||
return
|
||||
|
||||
title = (ticket.title or '').strip()
|
||||
if len(title) > 60:
|
||||
title = title[:57] + "..."
|
||||
|
||||
# Загрузим пользователя
|
||||
try:
|
||||
user = await get_user_by_id(db, ticket.user_id)
|
||||
except Exception:
|
||||
user = None
|
||||
full_name = user.full_name if user else "Unknown"
|
||||
telegram_id_display = user.telegram_id if user else "—"
|
||||
username_display = (user.username or "отсутствует") if user else "отсутствует"
|
||||
|
||||
# Обрезаем текст ответа для уведомления
|
||||
reply_preview = reply_text[:150] + "..." if len(reply_text) > 150 else reply_text
|
||||
|
||||
notification_text = (
|
||||
f"💬 <b>ОТВЕТ НА ТИКЕТ</b>\n\n"
|
||||
f"🆔 <b>ID тикета:</b> <code>{ticket.id}</code>\n"
|
||||
f"📝 <b>Заголовок:</b> {title or '—'}\n"
|
||||
f"👤 <b>Пользователь:</b> {full_name}\n"
|
||||
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
|
||||
f"📱 <b>Username:</b> @{username_display}\n\n"
|
||||
f"📩 <b>Сообщение:</b>\n{reply_preview}\n"
|
||||
)
|
||||
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
bot = maintenance_service._bot or None
|
||||
if bot is None:
|
||||
logger.warning("Bot instance is not available for admin notifications")
|
||||
return
|
||||
|
||||
service = AdminNotificationService(bot)
|
||||
result = await service.send_ticket_event_notification(notification_text, None)
|
||||
logger.info(f"Ticket #{ticket.id} reply notification sent: {result}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error notifying admins about ticket reply: {e}")
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
"""Регистрация обработчиков тикетов"""
|
||||
|
||||
|
||||
@@ -628,6 +628,16 @@ def get_referral_contest_manage_keyboard(
|
||||
callback_data=f"admin_contest_edit_times_{contest_id}",
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔄 Синхронизация",
|
||||
callback_data=f"admin_contest_sync_{contest_id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🔍 Отладка",
|
||||
callback_data=f"admin_contest_debug_{contest_id}",
|
||||
),
|
||||
],
|
||||
]
|
||||
|
||||
if can_delete:
|
||||
@@ -837,12 +847,91 @@ def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
callback_data="admin_msg_history"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_PINNED_MESSAGE", "📌 Закрепленное сообщение"),
|
||||
callback_data="admin_pinned_message",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_pinned_message_keyboard(
|
||||
language: str = "ru",
|
||||
send_before_menu: bool = True,
|
||||
send_on_every_start: bool = True,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
position_label = (
|
||||
_t(texts, "ADMIN_PINNED_POSITION_BEFORE", "⬆️ Показать перед меню")
|
||||
if send_before_menu
|
||||
else _t(texts, "ADMIN_PINNED_POSITION_AFTER", "⬇️ Показать после меню")
|
||||
)
|
||||
toggle_callback = "admin_pinned_message_position"
|
||||
|
||||
start_mode_label = (
|
||||
_t(texts, "ADMIN_PINNED_START_EVERY_TIME", "🔁 Показать при каждом /start")
|
||||
if send_on_every_start
|
||||
else _t(texts, "ADMIN_PINNED_START_ONCE", "🚫 Показывать только один раз")
|
||||
)
|
||||
start_mode_callback = "admin_pinned_message_start_mode"
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_PINNED_MESSAGE_UPDATE", "✏️ Обновить"),
|
||||
callback_data="admin_pinned_message_edit",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=position_label,
|
||||
callback_data=toggle_callback,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=start_mode_label,
|
||||
callback_data=start_mode_callback,
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_PINNED_MESSAGE_DELETE", "🗑️ Удалить и отключить"),
|
||||
callback_data="admin_pinned_message_delete",
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")],
|
||||
])
|
||||
|
||||
|
||||
def get_pinned_broadcast_confirm_keyboard(
|
||||
language: str = "ru",
|
||||
pinned_message_id: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура для выбора: разослать сейчас или только при /start."""
|
||||
texts = get_texts(language)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_PINNED_BROADCAST_NOW", "📨 Разослать сейчас всем"),
|
||||
callback_data=f"admin_pinned_broadcast_now:{pinned_message_id}",
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_PINNED_BROADCAST_ON_START", "⏳ Только при /start"),
|
||||
callback_data=f"admin_pinned_broadcast_skip:{pinned_message_id}",
|
||||
)
|
||||
],
|
||||
])
|
||||
|
||||
|
||||
def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -1004,6 +1093,14 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка управления ограничениями
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, "ADMIN_USER_RESTRICTIONS", "⚠️ Ограничить"),
|
||||
callback_data=f"admin_user_restrictions_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if user_status == "active":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -1041,6 +1138,65 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_user_restrictions_keyboard(
|
||||
user_id: int,
|
||||
restriction_topup: bool,
|
||||
restriction_subscription: bool,
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура управления ограничениями пользователя."""
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard = []
|
||||
|
||||
# Toggle для ограничения пополнения
|
||||
topup_emoji = "🚫" if restriction_topup else "✅"
|
||||
topup_text = f"{topup_emoji} Пополнение"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=topup_text,
|
||||
callback_data=f"admin_user_restriction_toggle_topup_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
# Toggle для ограничения подписки
|
||||
sub_emoji = "🚫" if restriction_subscription else "✅"
|
||||
sub_text = f"{sub_emoji} Продление/покупка"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=sub_text,
|
||||
callback_data=f"admin_user_restriction_toggle_sub_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка изменения причины
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="📝 Изменить причину",
|
||||
callback_data=f"admin_user_restriction_reason_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка снятия всех ограничений (если есть хотя бы одно)
|
||||
if restriction_topup or restriction_subscription:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text="🔓 Снять все ограничения",
|
||||
callback_data=f"admin_user_restriction_clear_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка назад
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data=f"admin_user_manage_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_user_promo_group_keyboard(
|
||||
promo_groups: List[Tuple[Any, int]],
|
||||
user_id: int,
|
||||
|
||||
+77
-20
@@ -438,13 +438,14 @@ def get_main_menu_keyboard(
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}, balance={balance_kopeks}")
|
||||
|
||||
if hasattr(texts, 'BALANCE_BUTTON') and balance_kopeks > 0:
|
||||
balance_button_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(balance_kopeks))
|
||||
safe_balance = balance_kopeks or 0
|
||||
if hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
|
||||
balance_button_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
|
||||
else:
|
||||
balance_button_text = texts.t(
|
||||
"BALANCE_BUTTON_DEFAULT",
|
||||
"💰 Баланс: {balance}",
|
||||
).format(balance=texts.format_price(balance_kopeks))
|
||||
).format(balance=texts.format_price(safe_balance))
|
||||
|
||||
keyboard: list[list[InlineKeyboardButton]] = []
|
||||
paired_buttons: list[InlineKeyboardButton] = []
|
||||
@@ -507,7 +508,14 @@ def get_main_menu_keyboard(
|
||||
)
|
||||
|
||||
# Добавляем кнопку докупки трафика для лимитированных подписок
|
||||
if subscription and not subscription.is_trial and subscription.traffic_limit_gb > 0:
|
||||
if (
|
||||
settings.BUY_TRAFFIC_BUTTON_VISIBLE
|
||||
and settings.is_traffic_topup_enabled()
|
||||
and not settings.is_traffic_topup_blocked()
|
||||
and subscription
|
||||
and not subscription.is_trial
|
||||
and (subscription.traffic_limit_gb or 0) > 0
|
||||
):
|
||||
paired_buttons.append(
|
||||
InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic")
|
||||
)
|
||||
@@ -985,7 +993,20 @@ def get_subscription_keyboard(
|
||||
callback_data="subscription_settings",
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка докупки трафика для платных подписок
|
||||
if (
|
||||
settings.is_traffic_topup_enabled()
|
||||
and not settings.is_traffic_topup_blocked()
|
||||
and subscription
|
||||
and (subscription.traffic_limit_gb or 0) > 0
|
||||
):
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"),
|
||||
callback_data="buy_traffic"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
])
|
||||
@@ -1127,10 +1148,10 @@ def get_subscription_period_keyboard(
|
||||
def get_traffic_packages_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
|
||||
if settings.is_traffic_topup_blocked():
|
||||
return get_back_keyboard(language)
|
||||
|
||||
logger.info(f"🔍 RAW CONFIG: '{settings.TRAFFIC_PACKAGES_CONFIG}'")
|
||||
@@ -1372,9 +1393,10 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_platega_enabled() and settings.get_platega_active_methods():
|
||||
platega_name = settings.get_platega_display_name()
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
|
||||
text=texts.t("PAYMENT_PLATEGA", f"💳 {platega_name}"),
|
||||
callback_data=_build_callback("platega"),
|
||||
)
|
||||
])
|
||||
@@ -1398,6 +1420,15 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_cloudpayments_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAYMENT_CLOUDPAYMENTS", "💳 Банковская карта (CloudPayments)"),
|
||||
callback_data=_build_callback("cloudpayments")
|
||||
)
|
||||
])
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_support_topup_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -1683,7 +1714,7 @@ def get_add_traffic_keyboard(
|
||||
if months_multiplier > 1:
|
||||
period_text = f" (за {months_multiplier} мес)"
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
||||
|
||||
if not enabled_packages:
|
||||
@@ -1839,27 +1870,45 @@ def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, lang
|
||||
])
|
||||
|
||||
|
||||
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
def get_reset_traffic_confirm_keyboard(
|
||||
price_kopeks: int,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
has_enough_balance: bool = True,
|
||||
missing_kopeks: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
|
||||
if settings.is_traffic_topup_blocked():
|
||||
return get_back_keyboard(language)
|
||||
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
buttons = []
|
||||
|
||||
if has_enough_balance:
|
||||
# Достаточно средств - показываем кнопку сброса
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"✅ Сбросить за {settings.format_price(price_kopeks)}",
|
||||
callback_data="confirm_reset_traffic"
|
||||
)
|
||||
],
|
||||
[
|
||||
])
|
||||
else:
|
||||
# Не хватает средств - показываем кнопку пополнения
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PENDING_CANCEL_BUTTON", "⌛ Отмена"),
|
||||
callback_data="menu_subscription",
|
||||
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
|
||||
callback_data=f"topup_amount_{missing_kopeks}"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.BACK,
|
||||
callback_data="subscription_settings",
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def get_manage_countries_keyboard(
|
||||
countries: List[dict],
|
||||
@@ -2370,6 +2419,14 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
|
||||
)
|
||||
])
|
||||
|
||||
if settings.is_modem_enabled():
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MODEM_BUTTON", "📡 Модем"),
|
||||
callback_data="subscription_modem"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Local libraries
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Asynchronous Python client for Russian self-employed tax service (Moy Nalog).
|
||||
|
||||
This is a Python port of the PHP library shoman4eg/moy-nalog,
|
||||
providing async HTTP client for interaction with lknpd.nalog.ru API.
|
||||
|
||||
Original PHP library: https://github.com/shoman4eg/moy-nalog
|
||||
Author: Artem Dubinin <artem@dubinin.me>
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from .client import Client
|
||||
from .exceptions import (
|
||||
ClientException,
|
||||
DomainException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
PhoneException,
|
||||
ServerException,
|
||||
UnauthorizedException,
|
||||
UnknownErrorException,
|
||||
ValidationException,
|
||||
)
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__all__ = [
|
||||
"Client",
|
||||
"ClientException",
|
||||
"DomainException",
|
||||
"ForbiddenException",
|
||||
"NotFoundException",
|
||||
"PhoneException",
|
||||
"ServerException",
|
||||
"UnauthorizedException",
|
||||
"UnknownErrorException",
|
||||
"ValidationException",
|
||||
]
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Internal HTTP client and authentication middleware.
|
||||
Based on PHP library's AuthenticationPlugin and HTTP architecture.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .exceptions import raise_for_status
|
||||
|
||||
|
||||
class AuthProvider(ABC):
|
||||
"""Abstract interface for authentication provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_token(self) -> dict[str, Any] | None:
|
||||
"""Get current access token data."""
|
||||
|
||||
@abstractmethod
|
||||
async def refresh(self, refresh_token: str) -> dict[str, Any] | None:
|
||||
"""Refresh access token using refresh token."""
|
||||
|
||||
|
||||
class AsyncHTTPClient:
|
||||
"""
|
||||
Async HTTP client with automatic token refresh on 401 responses.
|
||||
|
||||
Based on PHP's AuthenticationPlugin behavior:
|
||||
- Adds Bearer authorization header
|
||||
- On 401 response, attempts token refresh once
|
||||
- Retries request with new token (max 2 attempts)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
auth_provider: AuthProvider,
|
||||
default_headers: dict[str, str] | None = None,
|
||||
timeout: float = 10.0,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.auth_provider = auth_provider
|
||||
self.default_headers = default_headers or {}
|
||||
self.timeout = timeout
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
self.max_retries = 2 # Same as PHP AuthenticationPlugin::RETRY_LIMIT
|
||||
|
||||
async def _get_auth_headers(self) -> dict[str, str]:
|
||||
"""Get authorization headers from current token."""
|
||||
token_data = await self.auth_provider.get_token()
|
||||
if not token_data or "token" not in token_data:
|
||||
return {}
|
||||
|
||||
return {"Authorization": f"Bearer {token_data['token']}"}
|
||||
|
||||
async def _handle_401_response(
|
||||
self, client: httpx.AsyncClient, request: httpx.Request
|
||||
) -> httpx.Response | None:
|
||||
"""
|
||||
Handle 401 response by refreshing token and retrying request.
|
||||
|
||||
Uses asyncio.Lock to prevent concurrent refresh attempts,
|
||||
similar to PHP's retry storage mechanism.
|
||||
"""
|
||||
async with self._refresh_lock:
|
||||
token_data = await self.auth_provider.get_token()
|
||||
if not token_data or "refreshToken" not in token_data:
|
||||
return None
|
||||
|
||||
# Attempt token refresh
|
||||
new_token_data = await self.auth_provider.refresh(
|
||||
token_data["refreshToken"]
|
||||
)
|
||||
if not new_token_data or "token" not in new_token_data:
|
||||
return None
|
||||
|
||||
# Update request with new authorization header
|
||||
new_auth_headers = {"Authorization": f"Bearer {new_token_data['token']}"}
|
||||
request.headers.update(new_auth_headers)
|
||||
|
||||
# Retry request with new token
|
||||
return await client.send(request)
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
json_data: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> httpx.Response:
|
||||
"""
|
||||
Make HTTP request with automatic auth and 401 retry logic.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: API path (e.g., "/income")
|
||||
headers: Additional headers
|
||||
json_data: JSON request body
|
||||
**kwargs: Additional httpx.AsyncClient.request arguments
|
||||
|
||||
Returns:
|
||||
httpx.Response object
|
||||
|
||||
Raises:
|
||||
Domain exceptions via raise_for_status()
|
||||
"""
|
||||
# Prepare headers
|
||||
request_headers = self.default_headers.copy()
|
||||
auth_headers = await self._get_auth_headers()
|
||||
request_headers.update(auth_headers)
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
# Prepare request parameters
|
||||
request_kwargs = {
|
||||
"method": method,
|
||||
"url": self.base_url + path,
|
||||
"headers": request_headers,
|
||||
"timeout": self.timeout,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
if json_data is not None:
|
||||
request_kwargs["json"] = json_data
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Initial request
|
||||
response = await client.request(**request_kwargs)
|
||||
|
||||
# Handle 401 with token refresh (max 1 retry)
|
||||
if response.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
# Build request object for retry
|
||||
request = client.build_request(**request_kwargs)
|
||||
retry_response = await self._handle_401_response(client, request)
|
||||
if retry_response is not None:
|
||||
response = retry_response
|
||||
|
||||
# Check for domain exceptions
|
||||
raise_for_status(response)
|
||||
|
||||
return response
|
||||
|
||||
async def get(
|
||||
self,
|
||||
path: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> httpx.Response:
|
||||
"""GET request."""
|
||||
return await self.request("GET", path, headers=headers, **kwargs)
|
||||
|
||||
async def post(
|
||||
self,
|
||||
path: str,
|
||||
json_data: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> httpx.Response:
|
||||
"""POST request with JSON data."""
|
||||
return await self.request(
|
||||
"POST", path, headers=headers, json_data=json_data, **kwargs
|
||||
)
|
||||
|
||||
async def put(
|
||||
self,
|
||||
path: str,
|
||||
json_data: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> httpx.Response:
|
||||
"""PUT request with JSON data."""
|
||||
return await self.request(
|
||||
"PUT", path, headers=headers, json_data=json_data, **kwargs
|
||||
)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
path: str,
|
||||
headers: dict[str, str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> httpx.Response:
|
||||
"""DELETE request."""
|
||||
return await self.request("DELETE", path, headers=headers, **kwargs)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Authentication provider implementation.
|
||||
Based on PHP library's Authenticator class.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ._http import AuthProvider
|
||||
from .dto.device import DeviceInfo
|
||||
from .exceptions import raise_for_status
|
||||
|
||||
|
||||
def generate_device_id() -> str:
|
||||
"""Generate device ID similar to PHP's DeviceIdGenerator."""
|
||||
return str(uuid.uuid4()).replace("-", "")[:21].lower()
|
||||
|
||||
|
||||
# DeviceInfo is now imported from dto.device
|
||||
|
||||
|
||||
class AuthProviderImpl(AuthProvider):
|
||||
"""
|
||||
Authentication provider implementation.
|
||||
|
||||
Provides methods for:
|
||||
- Username/password authentication (INN + password)
|
||||
- Phone-based authentication (2-step: challenge + verify)
|
||||
- Token refresh
|
||||
- Token storage (in-memory or file-based)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://lknpd.nalog.ru/api",
|
||||
storage_path: str | None = None,
|
||||
device_id: str | None = None,
|
||||
):
|
||||
self.base_url_v1 = f"{base_url}/v1"
|
||||
self.base_url_v2 = f"{base_url}/v2"
|
||||
self.storage_path = storage_path
|
||||
self.device_id = device_id or generate_device_id()
|
||||
self.device_info = DeviceInfo(sourceDeviceId=self.device_id)
|
||||
self._token_data: dict[str, Any] | None = None
|
||||
|
||||
# Default headers similar to PHP Authenticator
|
||||
self.default_headers = {
|
||||
"Referrer": "https://lknpd.nalog.ru/auth/login",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
|
||||
# Load token from storage if available
|
||||
if self.storage_path:
|
||||
self._load_token_from_storage()
|
||||
|
||||
def _load_token_from_storage(self) -> None:
|
||||
"""Load token from file storage."""
|
||||
if not self.storage_path:
|
||||
return
|
||||
storage_path = Path(self.storage_path)
|
||||
if not storage_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with storage_path.open(encoding="utf-8") as f:
|
||||
self._token_data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Ignore errors, token will be None
|
||||
pass
|
||||
|
||||
def _save_token_to_storage(self) -> None:
|
||||
"""Save token to file storage."""
|
||||
if not self.storage_path or not self._token_data:
|
||||
return
|
||||
|
||||
storage_path = Path(self.storage_path)
|
||||
try:
|
||||
# Ensure directory exists
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with storage_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(self._token_data, f, ensure_ascii=False, indent=2)
|
||||
except OSError:
|
||||
# Ignore storage errors
|
||||
pass
|
||||
|
||||
async def get_token(self) -> dict[str, Any] | None:
|
||||
"""Get current access token data."""
|
||||
return self._token_data
|
||||
|
||||
async def set_token(self, token_json: str) -> None:
|
||||
"""
|
||||
Set access token from JSON string.
|
||||
|
||||
Args:
|
||||
token_json: JSON string containing token data
|
||||
"""
|
||||
try:
|
||||
self._token_data = json.loads(token_json)
|
||||
self._save_token_to_storage()
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid token JSON: {e}") from e
|
||||
|
||||
async def create_new_access_token(self, username: str, password: str) -> str:
|
||||
"""
|
||||
Create new access token using INN and password.
|
||||
|
||||
Mirrors PHP Authenticator::createAccessToken().
|
||||
|
||||
Args:
|
||||
username: INN (tax identification number)
|
||||
password: Password
|
||||
|
||||
Returns:
|
||||
JSON string with token data
|
||||
|
||||
Raises:
|
||||
Domain exceptions for authentication errors
|
||||
"""
|
||||
request_data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"deviceInfo": self.device_info.model_dump(),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url_v1}/auth/lkfl",
|
||||
json=request_data,
|
||||
headers=self.default_headers,
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
raise_for_status(response)
|
||||
|
||||
# Store and return token
|
||||
token_json = response.text
|
||||
await self.set_token(token_json)
|
||||
return token_json
|
||||
|
||||
async def create_phone_challenge(self, phone: str) -> dict[str, Any]:
|
||||
"""
|
||||
Start phone-based authentication challenge.
|
||||
|
||||
Mirrors PHP ApiClient::createPhoneChallenge() - uses v2 API.
|
||||
|
||||
Args:
|
||||
phone: Phone number (e.g., "79000000000")
|
||||
|
||||
Returns:
|
||||
Dictionary with challengeToken, expireDate, expireIn
|
||||
|
||||
Raises:
|
||||
Domain exceptions for API errors
|
||||
"""
|
||||
request_data = {
|
||||
"phone": phone,
|
||||
"requireTpToBeActive": True,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url_v2}/auth/challenge/sms/start",
|
||||
json=request_data,
|
||||
headers=self.default_headers,
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
raise_for_status(response)
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
|
||||
async def create_new_access_token_by_phone(
|
||||
self, phone: str, challenge_token: str, verification_code: str
|
||||
) -> str:
|
||||
"""
|
||||
Complete phone-based authentication with SMS code.
|
||||
|
||||
Mirrors PHP Authenticator::createAccessTokenByPhone().
|
||||
|
||||
Args:
|
||||
phone: Phone number
|
||||
challenge_token: Token from create_phone_challenge()
|
||||
verification_code: SMS verification code
|
||||
|
||||
Returns:
|
||||
JSON string with token data
|
||||
|
||||
Raises:
|
||||
Domain exceptions for authentication errors
|
||||
"""
|
||||
request_data = {
|
||||
"phone": phone,
|
||||
"code": verification_code,
|
||||
"challengeToken": challenge_token,
|
||||
"deviceInfo": self.device_info.model_dump(),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url_v1}/auth/challenge/sms/verify",
|
||||
json=request_data,
|
||||
headers=self.default_headers,
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
raise_for_status(response)
|
||||
|
||||
# Store and return token
|
||||
token_json = response.text
|
||||
await self.set_token(token_json)
|
||||
return token_json
|
||||
|
||||
async def refresh(self, refresh_token: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Refresh access token using refresh token.
|
||||
|
||||
Mirrors PHP Authenticator::refreshAccessToken().
|
||||
|
||||
Args:
|
||||
refresh_token: Refresh token string
|
||||
|
||||
Returns:
|
||||
New token data dictionary or None if refresh failed
|
||||
"""
|
||||
request_data = {
|
||||
"deviceInfo": self.device_info.model_dump(),
|
||||
"refreshToken": refresh_token,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url_v1}/auth/token",
|
||||
json=request_data,
|
||||
headers=self.default_headers,
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
# PHP version only checks for 200 status
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
return None
|
||||
|
||||
# Store and return new token data
|
||||
token_json = response.text
|
||||
await self.set_token(token_json)
|
||||
return self._token_data
|
||||
|
||||
except Exception:
|
||||
# Silently fail refresh attempts like PHP version
|
||||
return None
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
Main client facade for Moy Nalog API.
|
||||
Based on PHP library's ApiClient class.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from ._http import AsyncHTTPClient
|
||||
from .auth import AuthProviderImpl
|
||||
from .income import IncomeAPI
|
||||
from .payment_type import PaymentTypeAPI
|
||||
from .receipt import ReceiptAPI
|
||||
from .tax import TaxAPI
|
||||
from .user import UserAPI
|
||||
|
||||
|
||||
class Client:
|
||||
"""
|
||||
Main async client for Moy Nalog API.
|
||||
|
||||
Provides factory methods for API modules and authentication methods.
|
||||
Maps to PHP ApiClient functionality with async support.
|
||||
|
||||
Example:
|
||||
>>> client = Client()
|
||||
>>> token = await client.create_new_access_token("inn", "password")
|
||||
>>> await client.authenticate(token)
|
||||
>>> income_api = client.income()
|
||||
>>> result = await income_api.create("Service", 100, 1)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://lknpd.nalog.ru/api",
|
||||
storage_path: str | None = None,
|
||||
device_id: str | None = None,
|
||||
timeout: float = 10.0,
|
||||
):
|
||||
"""
|
||||
Initialize Moy Nalog API client.
|
||||
|
||||
Args:
|
||||
base_url: API base URL (default: https://lknpd.nalog.ru/api)
|
||||
storage_path: Optional file path for token storage
|
||||
device_id: Optional device ID (auto-generated if not provided)
|
||||
timeout: HTTP request timeout in seconds
|
||||
"""
|
||||
self.base_url = base_url
|
||||
self.timeout = timeout
|
||||
|
||||
# Initialize auth provider
|
||||
self.auth_provider = AuthProviderImpl(
|
||||
base_url=base_url,
|
||||
storage_path=storage_path,
|
||||
device_id=device_id,
|
||||
)
|
||||
|
||||
# Initialize HTTP client with auth middleware
|
||||
self.http_client = AsyncHTTPClient(
|
||||
base_url=f"{base_url}/v1",
|
||||
auth_provider=self.auth_provider,
|
||||
default_headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Referrer": "https://lknpd.nalog.ru/auth/login",
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# User profile data (for receipt operations)
|
||||
self._user_profile: dict[str, Any] | None = None
|
||||
|
||||
async def create_new_access_token(self, username: str, password: str) -> str:
|
||||
"""
|
||||
Create new access token using INN and password.
|
||||
|
||||
Maps to PHP ApiClient::createNewAccessToken().
|
||||
|
||||
Args:
|
||||
username: INN (tax identification number)
|
||||
password: Password
|
||||
|
||||
Returns:
|
||||
JSON string with access token data
|
||||
|
||||
Raises:
|
||||
UnauthorizedException: For invalid credentials
|
||||
DomainException: For other API errors
|
||||
"""
|
||||
return await self.auth_provider.create_new_access_token(username, password)
|
||||
|
||||
async def create_phone_challenge(self, phone: str) -> dict[str, Any]:
|
||||
"""
|
||||
Start phone-based authentication challenge.
|
||||
|
||||
Maps to PHP ApiClient::createPhoneChallenge().
|
||||
|
||||
Args:
|
||||
phone: Phone number (e.g., "79000000000")
|
||||
|
||||
Returns:
|
||||
Dictionary with challengeToken, expireDate, expireIn
|
||||
|
||||
Raises:
|
||||
DomainException: For API errors
|
||||
"""
|
||||
return await self.auth_provider.create_phone_challenge(phone)
|
||||
|
||||
async def create_new_access_token_by_phone(
|
||||
self, phone: str, challenge_token: str, verification_code: str
|
||||
) -> str:
|
||||
"""
|
||||
Complete phone-based authentication with SMS code.
|
||||
|
||||
Maps to PHP ApiClient::createNewAccessTokenByPhone().
|
||||
|
||||
Args:
|
||||
phone: Phone number
|
||||
challenge_token: Token from create_phone_challenge()
|
||||
verification_code: SMS verification code
|
||||
|
||||
Returns:
|
||||
JSON string with access token data
|
||||
|
||||
Raises:
|
||||
UnauthorizedException: For invalid verification
|
||||
DomainException: For other API errors
|
||||
"""
|
||||
return await self.auth_provider.create_new_access_token_by_phone(
|
||||
phone, challenge_token, verification_code
|
||||
)
|
||||
|
||||
async def authenticate(self, access_token: str) -> None:
|
||||
"""
|
||||
Authenticate client with access token.
|
||||
|
||||
Maps to PHP ApiClient::authenticate().
|
||||
|
||||
Args:
|
||||
access_token: JSON string with token data
|
||||
|
||||
Raises:
|
||||
ValueError: For invalid token JSON
|
||||
"""
|
||||
await self.auth_provider.set_token(access_token)
|
||||
|
||||
# Parse token to extract user profile (like PHP version)
|
||||
try:
|
||||
token_data = json.loads(access_token)
|
||||
if "profile" in token_data:
|
||||
self._user_profile = token_data["profile"]
|
||||
except json.JSONDecodeError:
|
||||
# If token parsing fails, profile will remain None
|
||||
pass
|
||||
|
||||
async def get_access_token(self) -> str | None:
|
||||
"""
|
||||
Get current access token (may be refreshed).
|
||||
|
||||
Maps to PHP ApiClient::getAccessToken().
|
||||
|
||||
Returns:
|
||||
Current access token JSON string or None
|
||||
"""
|
||||
token_data = await self.auth_provider.get_token()
|
||||
if token_data:
|
||||
return json.dumps(token_data)
|
||||
return None
|
||||
|
||||
def income(self) -> IncomeAPI:
|
||||
"""
|
||||
Get Income API instance.
|
||||
|
||||
Maps to PHP ApiClient::income().
|
||||
|
||||
Returns:
|
||||
IncomeAPI instance for creating/cancelling receipts
|
||||
"""
|
||||
return IncomeAPI(self.http_client)
|
||||
|
||||
def receipt(self) -> ReceiptAPI:
|
||||
"""
|
||||
Get Receipt API instance.
|
||||
|
||||
Maps to PHP ApiClient::receipt().
|
||||
|
||||
Returns:
|
||||
ReceiptAPI instance for accessing receipt data
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated (no profile data)
|
||||
"""
|
||||
if not self._user_profile or "inn" not in self._user_profile:
|
||||
raise ValueError("User profile not available. Please authenticate first.")
|
||||
|
||||
return ReceiptAPI(
|
||||
http_client=self.http_client,
|
||||
base_endpoint=self.base_url,
|
||||
user_inn=self._user_profile["inn"],
|
||||
)
|
||||
|
||||
def payment_type(self) -> PaymentTypeAPI:
|
||||
"""
|
||||
Get PaymentType API instance.
|
||||
|
||||
Maps to PHP ApiClient::paymentType().
|
||||
|
||||
Returns:
|
||||
PaymentTypeAPI instance for managing payment methods
|
||||
"""
|
||||
return PaymentTypeAPI(self.http_client)
|
||||
|
||||
def tax(self) -> TaxAPI:
|
||||
"""
|
||||
Get Tax API instance.
|
||||
|
||||
Maps to PHP ApiClient::tax().
|
||||
|
||||
Returns:
|
||||
TaxAPI instance for tax information and history
|
||||
"""
|
||||
return TaxAPI(self.http_client)
|
||||
|
||||
def user(self) -> UserAPI:
|
||||
"""
|
||||
Get User API instance.
|
||||
|
||||
Maps to PHP ApiClient::user().
|
||||
|
||||
Returns:
|
||||
UserAPI instance for user information
|
||||
"""
|
||||
return UserAPI(self.http_client)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Data Transfer Objects for Moy Nalog API."""
|
||||
|
||||
from .device import DeviceInfo
|
||||
from .income import (
|
||||
AtomDateTime,
|
||||
CancelCommentType,
|
||||
CancelRequest,
|
||||
IncomeClient,
|
||||
IncomeRequest,
|
||||
IncomeServiceItem,
|
||||
IncomeType,
|
||||
PaymentType,
|
||||
)
|
||||
from .invoice import InvoiceClient, InvoiceServiceItem
|
||||
from .payment_type import PaymentType as PaymentTypeModel
|
||||
from .payment_type import PaymentTypeCollection
|
||||
from .tax import History, HistoryRecords, Payment, PaymentRecords, Tax
|
||||
from .user import UserType
|
||||
|
||||
__all__ = [
|
||||
"AtomDateTime",
|
||||
"CancelCommentType",
|
||||
"CancelRequest",
|
||||
# Device DTOs
|
||||
"DeviceInfo",
|
||||
"History",
|
||||
"HistoryRecords",
|
||||
"IncomeClient",
|
||||
"IncomeRequest",
|
||||
"IncomeServiceItem",
|
||||
# Income DTOs
|
||||
"IncomeType",
|
||||
"InvoiceClient",
|
||||
# Invoice DTOs
|
||||
"InvoiceServiceItem",
|
||||
"Payment",
|
||||
"PaymentRecords",
|
||||
"PaymentType",
|
||||
"PaymentTypeCollection",
|
||||
# Payment Type DTOs
|
||||
"PaymentTypeModel",
|
||||
# Tax DTOs
|
||||
"Tax",
|
||||
# User DTOs
|
||||
"UserType",
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Device-related DTO models.
|
||||
Based on PHP library's DTO\\DeviceInfo class.
|
||||
"""
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""
|
||||
Device information model for API requests.
|
||||
Maps to PHP DTO\\DeviceInfo.
|
||||
"""
|
||||
|
||||
# Constants from PHP class
|
||||
SOURCE_TYPE_WEB: ClassVar[str] = "WEB"
|
||||
APP_VERSION: ClassVar[str] = "1.0.0"
|
||||
USER_AGENT: ClassVar[str] = (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_2) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36"
|
||||
)
|
||||
|
||||
source_type: str = Field(
|
||||
default=SOURCE_TYPE_WEB,
|
||||
alias="sourceType",
|
||||
description="Source type (usually WEB)",
|
||||
)
|
||||
source_device_id: str = Field(..., alias="sourceDeviceId", description="Device ID")
|
||||
app_version: str = Field(
|
||||
default=APP_VERSION, alias="appVersion", description="Application version"
|
||||
)
|
||||
user_agent: str = Field(default=USER_AGENT, description="User agent string")
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Custom serialization to match PHP jsonSerialize format."""
|
||||
_ = kwargs
|
||||
return {
|
||||
"sourceType": self.source_type,
|
||||
"sourceDeviceId": self.source_device_id,
|
||||
"appVersion": self.app_version,
|
||||
"metaDetails": {"userAgent": self.user_agent},
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Income-related DTO models.
|
||||
Based on PHP library's DTO and Enum classes.
|
||||
|
||||
MODIFIED: Исправлена работа с временем для nalog.ru
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime, timezone, timedelta
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_serializer, field_validator
|
||||
|
||||
|
||||
class IncomeType(str, Enum):
|
||||
"""Income type enumeration. Maps to PHP Enum\\IncomeType."""
|
||||
|
||||
FROM_INDIVIDUAL = "FROM_INDIVIDUAL"
|
||||
FROM_LEGAL_ENTITY = "FROM_LEGAL_ENTITY"
|
||||
FROM_FOREIGN_AGENCY = "FROM_FOREIGN_AGENCY"
|
||||
|
||||
|
||||
class PaymentType(str, Enum):
|
||||
"""Payment type enumeration. Maps to PHP Enum\\PaymentType."""
|
||||
|
||||
CASH = "CASH"
|
||||
ACCOUNT = "ACCOUNT"
|
||||
|
||||
|
||||
class CancelCommentType(str, Enum):
|
||||
"""Cancel comment type enumeration. Maps to PHP Enum\\CancelCommentType."""
|
||||
|
||||
CANCEL = "Чек сформирован ошибочно"
|
||||
REFUND = "Возврат средств"
|
||||
|
||||
|
||||
# Московская таймзона UTC+3
|
||||
MOSCOW_TZ = timezone(timedelta(hours=3))
|
||||
|
||||
|
||||
class AtomDateTime(BaseModel):
|
||||
"""
|
||||
DateTime wrapper for ISO/ATOM serialization.
|
||||
Maps to PHP DTO\\DateTime behavior.
|
||||
|
||||
Используем московское время с явным offset +03:00
|
||||
"""
|
||||
|
||||
value: datetime = Field(default_factory=lambda: datetime.now(MOSCOW_TZ))
|
||||
|
||||
@field_serializer("value")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with timezone offset (NOT Z suffix)."""
|
||||
# Убеждаемся что есть московская таймзона
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=MOSCOW_TZ)
|
||||
elif dt.tzinfo == UTC:
|
||||
dt = dt.astimezone(MOSCOW_TZ)
|
||||
|
||||
# Возвращаем с offset +03:00, НЕ конвертируем в UTC
|
||||
return dt.isoformat()
|
||||
|
||||
@classmethod
|
||||
def now(cls) -> "AtomDateTime":
|
||||
"""Create AtomDateTime with current Moscow time."""
|
||||
return cls(value=datetime.now(MOSCOW_TZ))
|
||||
|
||||
@classmethod
|
||||
def from_datetime(cls, dt: datetime) -> "AtomDateTime":
|
||||
"""Create AtomDateTime from datetime object."""
|
||||
return cls(value=dt)
|
||||
|
||||
|
||||
class IncomeServiceItem(BaseModel):
|
||||
"""
|
||||
Service item for income creation.
|
||||
Maps to PHP DTO\\IncomeServiceItem.
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="Service name/description")
|
||||
amount: Decimal = Field(..., description="Service amount", gt=0)
|
||||
quantity: Decimal = Field(..., description="Service quantity", gt=0)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
"""Validate name is not empty."""
|
||||
if not v.strip():
|
||||
raise ValueError("Name cannot be empty")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("amount", "quantity")
|
||||
@classmethod
|
||||
def validate_positive_decimal(cls, v: Decimal) -> Decimal:
|
||||
"""Validate decimal values are positive."""
|
||||
if v <= 0:
|
||||
raise ValueError("Amount and quantity must be greater than 0")
|
||||
return v
|
||||
|
||||
@field_serializer("amount", "quantity")
|
||||
def serialize_decimal(self, value: Decimal) -> str:
|
||||
"""Serialize Decimal as string (like PHP BigDecimal)."""
|
||||
return str(value)
|
||||
|
||||
def get_total_amount(self) -> Decimal:
|
||||
"""Calculate total amount (amount * quantity)."""
|
||||
return self.amount * self.quantity
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Custom serialization to match PHP jsonSerialize format."""
|
||||
_ = kwargs
|
||||
return {
|
||||
"name": self.name,
|
||||
"amount": str(self.amount),
|
||||
"quantity": str(self.quantity),
|
||||
}
|
||||
|
||||
|
||||
class IncomeClient(BaseModel):
|
||||
"""
|
||||
Client information for income creation.
|
||||
Maps to PHP DTO\\IncomeClient.
|
||||
"""
|
||||
|
||||
contact_phone: str | None = Field(default=None, description="Client contact phone")
|
||||
display_name: str | None = Field(default=None, description="Client display name")
|
||||
income_type: IncomeType = Field(
|
||||
default=IncomeType.FROM_INDIVIDUAL, description="Income type"
|
||||
)
|
||||
inn: str | None = Field(default=None, description="Client INN (tax ID)")
|
||||
|
||||
@field_validator("inn")
|
||||
@classmethod
|
||||
def validate_inn(cls, v: str | None, info: Any) -> str | None:
|
||||
"""Validate INN format for legal entities."""
|
||||
_ = info
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
# Remove any whitespace
|
||||
v = v.strip()
|
||||
if not v:
|
||||
return None
|
||||
|
||||
# Check if it's numeric
|
||||
if not v.isdigit():
|
||||
raise ValueError("INN must contain only numbers")
|
||||
|
||||
# Check length (10 for legal entities, 12 for individuals)
|
||||
if len(v) not in [10, 12]:
|
||||
raise ValueError("INN length must be 10 or 12 digits")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("display_name")
|
||||
@classmethod
|
||||
def validate_display_name_for_legal_entity(
|
||||
cls, v: str | None, info: Any
|
||||
) -> str | None:
|
||||
"""Validate display name is provided for legal entities."""
|
||||
_ = info
|
||||
# Note: This validation is applied in the API layer in PHP,
|
||||
# but we can do basic validation here
|
||||
if v is not None:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
return None
|
||||
return v
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Custom serialization to match PHP jsonSerialize format."""
|
||||
_ = kwargs
|
||||
return {
|
||||
"contactPhone": self.contact_phone,
|
||||
"displayName": self.display_name,
|
||||
"incomeType": self.income_type.value,
|
||||
"inn": self.inn,
|
||||
}
|
||||
|
||||
|
||||
class IncomeRequest(BaseModel):
|
||||
"""
|
||||
Complete income creation request.
|
||||
Maps to PHP request structure in Income::createMultipleItems().
|
||||
"""
|
||||
|
||||
operation_time: AtomDateTime = Field(default_factory=AtomDateTime.now)
|
||||
request_time: AtomDateTime = Field(default_factory=AtomDateTime.now)
|
||||
services: list[IncomeServiceItem] = Field(..., min_length=1)
|
||||
total_amount: str = Field(..., description="Total amount as string")
|
||||
client: IncomeClient = Field(default_factory=IncomeClient)
|
||||
payment_type: PaymentType = Field(default=PaymentType.CASH)
|
||||
ignore_max_total_income_restriction: bool = Field(default=False)
|
||||
|
||||
@field_validator("services")
|
||||
@classmethod
|
||||
def validate_services(cls, v: list[IncomeServiceItem]) -> list[IncomeServiceItem]:
|
||||
"""Validate services list is not empty."""
|
||||
if not v:
|
||||
raise ValueError("Services cannot be empty")
|
||||
return v
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Custom serialization to match PHP request format."""
|
||||
_ = kwargs
|
||||
return {
|
||||
"operationTime": self.operation_time.serialize_datetime(
|
||||
self.operation_time.value
|
||||
),
|
||||
"requestTime": self.request_time.serialize_datetime(
|
||||
self.request_time.value
|
||||
),
|
||||
"services": [service.model_dump() for service in self.services],
|
||||
"totalAmount": self.total_amount,
|
||||
"client": self.client.model_dump(),
|
||||
"paymentType": self.payment_type.value,
|
||||
"ignoreMaxTotalIncomeRestriction": self.ignore_max_total_income_restriction,
|
||||
}
|
||||
|
||||
|
||||
class CancelRequest(BaseModel):
|
||||
"""
|
||||
Income cancellation request.
|
||||
Maps to PHP request structure in Income::cancel().
|
||||
"""
|
||||
|
||||
operation_time: AtomDateTime = Field(default_factory=AtomDateTime.now)
|
||||
request_time: AtomDateTime = Field(default_factory=AtomDateTime.now)
|
||||
comment: CancelCommentType = Field(..., description="Cancellation reason")
|
||||
receipt_uuid: str = Field(..., description="Receipt UUID to cancel")
|
||||
partner_code: str | None = Field(default=None, description="Partner code")
|
||||
|
||||
@field_validator("receipt_uuid")
|
||||
@classmethod
|
||||
def validate_receipt_uuid(cls, v: str) -> str:
|
||||
"""Validate receipt UUID is not empty."""
|
||||
if not v.strip():
|
||||
raise ValueError("Receipt UUID cannot be empty")
|
||||
return v.strip()
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Custom serialization to match PHP request format."""
|
||||
_ = kwargs
|
||||
return {
|
||||
"operationTime": self.operation_time.serialize_datetime(
|
||||
self.operation_time.value
|
||||
),
|
||||
"requestTime": self.request_time.serialize_datetime(
|
||||
self.request_time.value
|
||||
),
|
||||
"comment": self.comment.value,
|
||||
"receiptUuid": self.receipt_uuid,
|
||||
"partnerCode": self.partner_code,
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Invoice-related DTO models.
|
||||
Based on PHP library's DTO classes for invoices.
|
||||
|
||||
Note: Invoice functionality is marked as "Not implemented" in the PHP library,
|
||||
but DTO structures are provided for future implementation.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
|
||||
|
||||
class InvoiceServiceItem(BaseModel):
|
||||
"""
|
||||
Invoice service item model.
|
||||
Maps to PHP DTO\\InvoiceServiceItem.
|
||||
|
||||
Similar to IncomeServiceItem but for invoices.
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="Service name/description")
|
||||
amount: Decimal = Field(..., description="Service amount", gt=0)
|
||||
quantity: Decimal = Field(..., description="Service quantity", gt=0)
|
||||
|
||||
@field_serializer("amount", "quantity")
|
||||
def serialize_decimal(self, value: Decimal) -> str:
|
||||
"""Serialize Decimal as string."""
|
||||
return str(value)
|
||||
|
||||
def get_total_amount(self) -> Decimal:
|
||||
"""Calculate total amount (amount * quantity)."""
|
||||
return self.amount * self.quantity
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Custom serialization to match API format."""
|
||||
_ = kwargs
|
||||
return {
|
||||
"name": self.name,
|
||||
"amount": str(self.amount),
|
||||
"quantity": str(self.quantity),
|
||||
}
|
||||
|
||||
|
||||
class InvoiceClient(BaseModel):
|
||||
"""
|
||||
Invoice client information model.
|
||||
Maps to PHP DTO\\InvoiceClient.
|
||||
|
||||
Similar to IncomeClient but for invoices.
|
||||
"""
|
||||
|
||||
contact_phone: str | None = Field(
|
||||
None, alias="contactPhone", description="Client contact phone"
|
||||
)
|
||||
display_name: str | None = Field(
|
||||
None, alias="displayName", description="Client display name"
|
||||
)
|
||||
inn: str | None = Field(None, description="Client INN")
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Custom serialization to match API format."""
|
||||
_ = kwargs
|
||||
return {
|
||||
"contactPhone": self.contact_phone,
|
||||
"displayName": self.display_name,
|
||||
"inn": self.inn,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
PaymentType-related DTO models.
|
||||
Based on PHP library's Model/PaymentType classes.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PaymentType(BaseModel):
|
||||
"""
|
||||
Payment type model.
|
||||
Maps to PHP Model\\PaymentType\\PaymentType.
|
||||
"""
|
||||
|
||||
id: int = Field(..., description="Payment type ID")
|
||||
type: str = Field(..., description="Payment type")
|
||||
bank_name: str = Field(..., alias="bankName", description="Bank name")
|
||||
bank_bik: str = Field(..., alias="bankBik", description="Bank BIK")
|
||||
corr_account: str = Field(
|
||||
..., alias="corrAccount", description="Correspondent account"
|
||||
)
|
||||
favorite: bool = Field(..., description="Is favorite payment type")
|
||||
phone: str | None = Field(None, description="Phone number")
|
||||
bank_id: str | None = Field(None, alias="bankId", description="Bank ID")
|
||||
current_account: str = Field(
|
||||
..., alias="currentAccount", description="Current account"
|
||||
)
|
||||
available_for_pa: bool = Field(
|
||||
..., alias="availableForPa", description="Available for PA"
|
||||
)
|
||||
|
||||
def is_favorite(self) -> bool:
|
||||
"""Check if this payment type is marked as favorite."""
|
||||
return self.favorite
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Custom serialization to match API format."""
|
||||
_ = kwargs
|
||||
return {
|
||||
"id": self.id,
|
||||
"type": self.type,
|
||||
"bankName": self.bank_name,
|
||||
"bankBik": self.bank_bik,
|
||||
"corrAccount": self.corr_account,
|
||||
"favorite": self.favorite,
|
||||
"phone": self.phone,
|
||||
"bankId": self.bank_id,
|
||||
"currentAccount": self.current_account,
|
||||
"availableForPa": self.available_for_pa,
|
||||
}
|
||||
|
||||
|
||||
class PaymentTypeCollection(BaseModel):
|
||||
"""
|
||||
Collection of payment types.
|
||||
Maps to PHP Model\\PaymentType\\PaymentTypeCollection.
|
||||
"""
|
||||
|
||||
payment_types: list[PaymentType] = Field(default_factory=list)
|
||||
|
||||
def __iter__(self) -> Any:
|
||||
"""Make collection iterable."""
|
||||
return iter(self.payment_types)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Get collection length."""
|
||||
return len(self.payment_types)
|
||||
|
||||
def __getitem__(self, index: int) -> PaymentType:
|
||||
"""Get item by index."""
|
||||
return self.payment_types[index]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Tax-related DTO models.
|
||||
Based on PHP library's Model/Tax classes.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Tax(BaseModel):
|
||||
"""
|
||||
Tax information model.
|
||||
Maps to PHP Model\\Tax\\Tax.
|
||||
"""
|
||||
|
||||
# Tax model fields would be defined based on API response structure
|
||||
# Since we don't have the exact PHP model structure, we'll use flexible Dict
|
||||
data: dict[str, Any] = Field(default_factory=dict, description="Tax data")
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Return the raw data dictionary."""
|
||||
_ = kwargs
|
||||
return self.data
|
||||
|
||||
|
||||
class History(BaseModel):
|
||||
"""
|
||||
Tax history entry model.
|
||||
Maps to PHP Model\\Tax\\History.
|
||||
"""
|
||||
|
||||
# History fields would be defined based on API response
|
||||
data: dict[str, Any] = Field(default_factory=dict, description="History entry data")
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Return the raw data dictionary."""
|
||||
_ = kwargs
|
||||
return self.data
|
||||
|
||||
|
||||
class HistoryRecords(BaseModel):
|
||||
"""
|
||||
Collection of tax history records.
|
||||
Maps to PHP Model\\Tax\\HistoryRecords.
|
||||
"""
|
||||
|
||||
records: list[History] = Field(default_factory=list, description="History records")
|
||||
|
||||
def __iter__(self) -> Any:
|
||||
"""Make collection iterable."""
|
||||
return iter(self.records)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Get collection length."""
|
||||
return len(self.records)
|
||||
|
||||
def __getitem__(self, index: int) -> History:
|
||||
"""Get item by index."""
|
||||
return self.records[index]
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
"""
|
||||
Tax payment model.
|
||||
Maps to PHP Model\\Tax\\Payment.
|
||||
"""
|
||||
|
||||
# Payment fields would be defined based on API response
|
||||
data: dict[str, Any] = Field(default_factory=dict, description="Payment data")
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Return the raw data dictionary."""
|
||||
_ = kwargs
|
||||
return self.data
|
||||
|
||||
|
||||
class PaymentRecords(BaseModel):
|
||||
"""
|
||||
Collection of tax payment records.
|
||||
Maps to PHP Model\\Tax\\PaymentRecords.
|
||||
"""
|
||||
|
||||
records: list[Payment] = Field(default_factory=list, description="Payment records")
|
||||
|
||||
def __iter__(self) -> Any:
|
||||
"""Make collection iterable."""
|
||||
return iter(self.records)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Get collection length."""
|
||||
return len(self.records)
|
||||
|
||||
def __getitem__(self, index: int) -> Payment:
|
||||
"""Get item by index."""
|
||||
return self.records[index]
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
User-related DTO models.
|
||||
Based on PHP library's Model/User classes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class UserType(BaseModel):
|
||||
"""
|
||||
User profile model.
|
||||
Maps to PHP Model\\User\\UserType.
|
||||
"""
|
||||
|
||||
id: int = Field(..., description="User ID")
|
||||
last_name: str | None = Field(None, alias="lastName", description="Last name")
|
||||
display_name: str = Field(..., alias="displayName", description="Display name")
|
||||
middle_name: str | None = Field(None, alias="middleName", description="Middle name")
|
||||
email: str | None = Field(None, description="Email address")
|
||||
phone: str = Field(..., description="Phone number")
|
||||
inn: str = Field(..., description="INN (tax identification number)")
|
||||
snils: str | None = Field(None, description="SNILS")
|
||||
avatar_exists: bool = Field(..., alias="avatarExists", description="Avatar exists")
|
||||
initial_registration_date: datetime | None = Field(
|
||||
None, alias="initialRegistrationDate", description="Initial registration date"
|
||||
)
|
||||
registration_date: datetime | None = Field(
|
||||
None, alias="registrationDate", description="Registration date"
|
||||
)
|
||||
first_receipt_register_time: datetime | None = Field(
|
||||
None,
|
||||
alias="firstReceiptRegisterTime",
|
||||
description="First receipt registration time",
|
||||
)
|
||||
first_receipt_cancel_time: datetime | None = Field(
|
||||
None,
|
||||
alias="firstReceiptCancelTime",
|
||||
description="First receipt cancellation time",
|
||||
)
|
||||
hide_cancelled_receipt: bool = Field(
|
||||
..., alias="hideCancelledReceipt", description="Hide cancelled receipts"
|
||||
)
|
||||
register_available: bool | str | None = Field(
|
||||
None, alias="registerAvailable", description="Register available (mixed type)"
|
||||
)
|
||||
status: str | None = Field(None, description="User status")
|
||||
restricted_mode: bool = Field(
|
||||
..., alias="restrictedMode", description="Restricted mode"
|
||||
)
|
||||
pfr_url: str | None = Field(None, alias="pfrUrl", description="PFR URL")
|
||||
login: str | None = Field(None, description="Login")
|
||||
|
||||
@field_validator(
|
||||
"initial_registration_date",
|
||||
"registration_date",
|
||||
"first_receipt_register_time",
|
||||
"first_receipt_cancel_time",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def parse_datetime(cls, v: Any) -> datetime | None:
|
||||
"""Parse datetime strings from API."""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return v # type: ignore[no-any-return]
|
||||
|
||||
def is_avatar_exists(self) -> bool:
|
||||
"""Check if user has avatar."""
|
||||
return self.avatar_exists
|
||||
|
||||
def is_hide_cancelled_receipt(self) -> bool:
|
||||
"""Check if cancelled receipts are hidden."""
|
||||
return self.hide_cancelled_receipt
|
||||
|
||||
def is_restricted_mode(self) -> bool:
|
||||
"""Check if user is in restricted mode."""
|
||||
return self.restricted_mode
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Custom serialization to match API format."""
|
||||
_ = kwargs
|
||||
|
||||
def serialize_datetime(dt: datetime | None) -> str | None:
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat()
|
||||
|
||||
return {
|
||||
"id": self.id,
|
||||
"lastName": self.last_name,
|
||||
"displayName": self.display_name,
|
||||
"middleName": self.middle_name,
|
||||
"email": self.email,
|
||||
"phone": self.phone,
|
||||
"inn": self.inn,
|
||||
"snils": self.snils,
|
||||
"avatarExists": self.avatar_exists,
|
||||
"initialRegistrationDate": serialize_datetime(
|
||||
self.initial_registration_date
|
||||
),
|
||||
"registrationDate": serialize_datetime(self.registration_date),
|
||||
"firstReceiptRegisterTime": serialize_datetime(
|
||||
self.first_receipt_register_time
|
||||
),
|
||||
"firstReceiptCancelTime": serialize_datetime(
|
||||
self.first_receipt_cancel_time
|
||||
),
|
||||
"hideCancelledReceipt": self.hide_cancelled_receipt,
|
||||
"registerAvailable": self.register_available,
|
||||
"status": self.status,
|
||||
"restrictedMode": self.restricted_mode,
|
||||
"pfrUrl": self.pfr_url,
|
||||
"login": self.login,
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Domain exceptions for Moy Nalog API.
|
||||
Mirrors PHP library's exception hierarchy and error handling.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DomainException(Exception): # noqa: N818 для совместимости публичного API
|
||||
"""Base domain exception for all Moy Nalog API errors."""
|
||||
|
||||
def __init__(self, message: str, response: httpx.Response | None = None):
|
||||
super().__init__(message)
|
||||
self.response = response
|
||||
|
||||
# Log the error with response details (without sensitive data)
|
||||
if response:
|
||||
self._log_error_details(message, response)
|
||||
|
||||
def _log_error_details(self, message: str, response: httpx.Response) -> None:
|
||||
"""Log error details while avoiding sensitive information."""
|
||||
# Mask potential sensitive data in URLs and headers
|
||||
safe_url = self._mask_sensitive_url(str(response.url))
|
||||
safe_headers = self._mask_sensitive_headers(dict(response.headers))
|
||||
|
||||
logger.error(
|
||||
"API Error: %s | Status: %d | URL: %s | Headers: %s | Body: %s",
|
||||
message,
|
||||
response.status_code,
|
||||
safe_url,
|
||||
safe_headers,
|
||||
self._get_safe_response_body(response),
|
||||
)
|
||||
|
||||
def _mask_sensitive_url(self, url: str) -> str:
|
||||
"""Mask potential sensitive data in URL."""
|
||||
# Replace tokens/keys with asterisks
|
||||
patterns = [
|
||||
(r"(token=)[^&]*", r"\1***"),
|
||||
(r"(key=)[^&]*", r"\1***"),
|
||||
(r"(secret=)[^&]*", r"\1***"),
|
||||
]
|
||||
|
||||
for pattern, replacement in patterns:
|
||||
url = re.sub(pattern, replacement, url)
|
||||
return url
|
||||
|
||||
def _mask_sensitive_headers(self, headers: dict[str, str]) -> dict[str, str]:
|
||||
"""Mask sensitive headers."""
|
||||
safe_headers = headers.copy()
|
||||
sensitive_keys = ["authorization", "x-api-key", "cookie", "set-cookie"]
|
||||
|
||||
for key in sensitive_keys:
|
||||
if key.lower() in [h.lower() for h in safe_headers]:
|
||||
# Find the actual key (case-insensitive)
|
||||
actual_key = next(k for k in safe_headers if k.lower() == key.lower())
|
||||
safe_headers[actual_key] = "***"
|
||||
|
||||
return safe_headers
|
||||
|
||||
def _get_safe_response_body(self, response: httpx.Response) -> str:
|
||||
"""Get response body with potential sensitive data masked."""
|
||||
try:
|
||||
body = response.text[:1000] # Limit body size for logging
|
||||
# Mask potential tokens in JSON responses
|
||||
patterns = [
|
||||
(r'("token":\s*")[^"]*(")', r"\1***\2"),
|
||||
(r'("refreshToken":\s*")[^"]*(")', r"\1***\2"),
|
||||
(r'("password":\s*")[^"]*(")', r"\1***\2"),
|
||||
(r'("secret":\s*")[^"]*(")', r"\1***\2"),
|
||||
]
|
||||
|
||||
for pattern, replacement in patterns:
|
||||
body = re.sub(pattern, replacement, body)
|
||||
|
||||
return body
|
||||
except Exception:
|
||||
return "[Failed to read response body]"
|
||||
|
||||
|
||||
class ValidationException(DomainException):
|
||||
"""HTTP 400 - Validation error."""
|
||||
|
||||
|
||||
class UnauthorizedException(DomainException):
|
||||
"""HTTP 401 - Authentication required or invalid credentials."""
|
||||
|
||||
|
||||
class ForbiddenException(DomainException):
|
||||
"""HTTP 403 - Access forbidden."""
|
||||
|
||||
|
||||
class NotFoundException(DomainException):
|
||||
"""HTTP 404 - Resource not found."""
|
||||
|
||||
|
||||
class ClientException(DomainException):
|
||||
"""HTTP 406 - Client error (e.g., wrong Accept headers)."""
|
||||
|
||||
|
||||
class PhoneException(DomainException):
|
||||
"""HTTP 422 - Phone-related error (SMS, verification, etc.)."""
|
||||
|
||||
|
||||
class ServerException(DomainException):
|
||||
"""HTTP 500 - Internal server error."""
|
||||
|
||||
|
||||
class UnknownErrorException(DomainException):
|
||||
"""Unknown HTTP error code."""
|
||||
|
||||
|
||||
def raise_for_status(response: httpx.Response) -> None:
|
||||
"""
|
||||
Raise appropriate domain exception based on HTTP status code.
|
||||
|
||||
Maps status codes to exceptions exactly like PHP ErrorHandler:
|
||||
- 400: ValidationException
|
||||
- 401: UnauthorizedException
|
||||
- 403: ForbiddenException
|
||||
- 404: NotFoundException
|
||||
- 406: ClientException
|
||||
- 422: PhoneException
|
||||
- 500: ServerException
|
||||
- default: UnknownErrorException
|
||||
|
||||
Args:
|
||||
response: httpx.Response object
|
||||
|
||||
Raises:
|
||||
DomainException: Appropriate exception for status code
|
||||
"""
|
||||
if response.status_code < HTTPStatus.BAD_REQUEST:
|
||||
return
|
||||
|
||||
body = response.text
|
||||
|
||||
if response.status_code == HTTPStatus.BAD_REQUEST:
|
||||
raise ValidationException(body, response)
|
||||
if response.status_code == HTTPStatus.UNAUTHORIZED:
|
||||
raise UnauthorizedException(body, response)
|
||||
if response.status_code == HTTPStatus.FORBIDDEN:
|
||||
raise ForbiddenException(body, response)
|
||||
if response.status_code == HTTPStatus.NOT_FOUND:
|
||||
raise NotFoundException(body, response)
|
||||
if response.status_code == HTTPStatus.NOT_ACCEPTABLE:
|
||||
raise ClientException("Wrong Accept headers", response)
|
||||
if response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY:
|
||||
raise PhoneException(body, response)
|
||||
if response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
|
||||
raise ServerException(body, response)
|
||||
raise UnknownErrorException(body, response)
|
||||
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Income API implementation.
|
||||
Based on PHP library's Api\\Income class.
|
||||
"""
|
||||
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
|
||||
from ._http import AsyncHTTPClient
|
||||
from .dto.income import (
|
||||
AtomDateTime,
|
||||
CancelCommentType,
|
||||
CancelRequest,
|
||||
IncomeClient,
|
||||
IncomeRequest,
|
||||
IncomeServiceItem,
|
||||
IncomeType,
|
||||
PaymentType,
|
||||
)
|
||||
|
||||
|
||||
class IncomeAPI:
|
||||
"""
|
||||
Income API for creating and managing receipts.
|
||||
|
||||
Provides async methods for:
|
||||
- Creating income receipts (single or multiple items)
|
||||
- Cancelling income receipts
|
||||
|
||||
Maps to PHP Api\\Income functionality.
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncHTTPClient):
|
||||
self.http = http_client
|
||||
|
||||
async def create(
|
||||
self,
|
||||
name: str,
|
||||
amount: Decimal | float | int | str,
|
||||
quantity: Decimal | float | int | str = 1,
|
||||
operation_time: datetime | None = None,
|
||||
client: IncomeClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create income receipt with single service item.
|
||||
|
||||
Maps to PHP Income::create() method.
|
||||
|
||||
Args:
|
||||
name: Service name/description
|
||||
amount: Service amount (converted to Decimal)
|
||||
quantity: Service quantity (converted to Decimal, default: 1)
|
||||
operation_time: Operation datetime (default: now)
|
||||
client: Client information (default: individual client)
|
||||
|
||||
Returns:
|
||||
Dictionary with response data including approvedReceiptUuid
|
||||
|
||||
Raises:
|
||||
ValidationException: For validation errors
|
||||
DomainException: For other API errors
|
||||
"""
|
||||
# Convert to IncomeServiceItem
|
||||
service_item = IncomeServiceItem(
|
||||
name=name,
|
||||
amount=Decimal(str(amount)),
|
||||
quantity=Decimal(str(quantity)),
|
||||
)
|
||||
|
||||
return await self.create_multiple_items([service_item], operation_time, client)
|
||||
|
||||
async def create_multiple_items(
|
||||
self,
|
||||
services: list[IncomeServiceItem],
|
||||
operation_time: datetime | None = None,
|
||||
client: IncomeClient | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create income receipt with multiple service items.
|
||||
|
||||
Maps to PHP Income::createMultipleItems() method.
|
||||
|
||||
Args:
|
||||
services: List of service items
|
||||
operation_time: Operation datetime (default: now)
|
||||
client: Client information (default: individual client)
|
||||
|
||||
Returns:
|
||||
Dictionary with response data including approvedReceiptUuid
|
||||
|
||||
Raises:
|
||||
ValidationException: For validation errors (empty items, invalid amounts, etc.)
|
||||
DomainException: For other API errors
|
||||
"""
|
||||
if not services:
|
||||
raise ValueError("Services cannot be empty")
|
||||
|
||||
# Validate client for legal entity (mirrors PHP validation)
|
||||
if client and client.income_type == IncomeType.FROM_LEGAL_ENTITY:
|
||||
if not client.inn:
|
||||
raise ValueError("Client INN cannot be empty for legal entity")
|
||||
if not client.display_name:
|
||||
raise ValueError("Client DisplayName cannot be empty for legal entity")
|
||||
|
||||
# Calculate total amount (mirrors PHP BigDecimal logic)
|
||||
total_amount = sum(item.get_total_amount() for item in services)
|
||||
|
||||
# Create request object
|
||||
request = IncomeRequest(
|
||||
operation_time=(
|
||||
AtomDateTime.from_datetime(operation_time)
|
||||
if operation_time
|
||||
else AtomDateTime.now()
|
||||
),
|
||||
request_time=AtomDateTime.now(),
|
||||
services=services,
|
||||
total_amount=str(total_amount),
|
||||
client=client or IncomeClient(),
|
||||
payment_type=PaymentType.CASH,
|
||||
ignore_max_total_income_restriction=False,
|
||||
)
|
||||
|
||||
# Make API request
|
||||
response = await self.http.post("/income", json_data=request.model_dump())
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
|
||||
async def cancel(
|
||||
self,
|
||||
receipt_uuid: str,
|
||||
comment: CancelCommentType | str,
|
||||
operation_time: datetime | None = None,
|
||||
request_time: datetime | None = None,
|
||||
partner_code: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Cancel income receipt.
|
||||
|
||||
Maps to PHP Income::cancel() method.
|
||||
|
||||
Args:
|
||||
receipt_uuid: Receipt UUID to cancel
|
||||
comment: Cancellation reason (enum or string)
|
||||
operation_time: Operation datetime (default: now)
|
||||
request_time: Request datetime (default: now)
|
||||
partner_code: Partner code (optional)
|
||||
|
||||
Returns:
|
||||
Dictionary with cancellation response data
|
||||
|
||||
Raises:
|
||||
ValidationException: For validation errors (empty UUID, invalid comment)
|
||||
DomainException: For other API errors
|
||||
"""
|
||||
# Validate receipt UUID
|
||||
if not receipt_uuid.strip():
|
||||
raise ValueError("Receipt UUID cannot be empty")
|
||||
|
||||
# Convert comment to enum if string
|
||||
if isinstance(comment, str):
|
||||
# Try to find matching enum value
|
||||
comment_enum = None
|
||||
for enum_val in CancelCommentType:
|
||||
if enum_val.value == comment:
|
||||
comment_enum = enum_val
|
||||
break
|
||||
|
||||
if comment_enum is None:
|
||||
valid_comments = [e.value for e in CancelCommentType]
|
||||
raise ValueError(
|
||||
f"Comment is invalid. Must be one of: {valid_comments}"
|
||||
)
|
||||
|
||||
comment = comment_enum
|
||||
|
||||
# Create request object
|
||||
request = CancelRequest(
|
||||
operation_time=(
|
||||
AtomDateTime.from_datetime(operation_time)
|
||||
if operation_time
|
||||
else AtomDateTime.now()
|
||||
),
|
||||
request_time=(
|
||||
AtomDateTime.from_datetime(request_time)
|
||||
if request_time
|
||||
else AtomDateTime.now()
|
||||
),
|
||||
comment=comment,
|
||||
receipt_uuid=receipt_uuid.strip(),
|
||||
partner_code=partner_code,
|
||||
)
|
||||
|
||||
# Make API request
|
||||
response = await self.http.post("/cancel", json_data=request.model_dump())
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
from_date: Optional[date] = None,
|
||||
to_date: Optional[date] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get list of income records for a period.
|
||||
|
||||
Args:
|
||||
from_date: Start date (default: 30 days ago)
|
||||
to_date: End date (default: today)
|
||||
limit: Maximum number of records (default: 100)
|
||||
offset: Offset for pagination (default: 0)
|
||||
|
||||
Returns:
|
||||
Dictionary with income records list
|
||||
|
||||
Raises:
|
||||
DomainException: For API errors
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
if from_date is None:
|
||||
from_date = date.today() - timedelta(days=30)
|
||||
if to_date is None:
|
||||
to_date = date.today()
|
||||
|
||||
# API использует GET с query параметрами
|
||||
params = {
|
||||
"from": from_date.isoformat(),
|
||||
"to": to_date.isoformat(),
|
||||
"limit": str(limit),
|
||||
"offset": str(offset),
|
||||
"sortBy": "OPERATION_TIME",
|
||||
"sortOrder": "DESC",
|
||||
}
|
||||
|
||||
# Формируем query string
|
||||
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
response = await self.http.get(f"/incomes?{query}")
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
PaymentType API implementation.
|
||||
Based on PHP library's Api\\PaymentType class.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ._http import AsyncHTTPClient
|
||||
|
||||
|
||||
class PaymentTypeAPI:
|
||||
"""
|
||||
PaymentType API for managing payment methods.
|
||||
|
||||
Provides async methods for:
|
||||
- Getting payment types table
|
||||
- Finding favorite payment type
|
||||
|
||||
Maps to PHP Api\\PaymentType functionality.
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncHTTPClient):
|
||||
self.http = http_client
|
||||
|
||||
async def table(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get all available payment types.
|
||||
|
||||
Maps to PHP PaymentType::table().
|
||||
|
||||
Returns:
|
||||
List of payment type dictionaries with bank information
|
||||
|
||||
Raises:
|
||||
DomainException: For API errors
|
||||
"""
|
||||
response = await self.http.get("/payment-type/table")
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
|
||||
async def favorite(self) -> dict[str, Any] | None:
|
||||
"""
|
||||
Get favorite payment type.
|
||||
|
||||
Maps to PHP PaymentType::favorite().
|
||||
Finds the first payment type marked as favorite from the table.
|
||||
|
||||
Returns:
|
||||
Payment type dictionary if favorite found, None otherwise
|
||||
|
||||
Raises:
|
||||
DomainException: For API errors
|
||||
"""
|
||||
payment_types = await self.table()
|
||||
|
||||
# Find first payment type with favorite=True
|
||||
for payment_type in payment_types:
|
||||
if payment_type.get("favorite", False):
|
||||
return payment_type
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Receipt API implementation.
|
||||
Based on PHP library's Api\\Receipt class.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ._http import AsyncHTTPClient
|
||||
|
||||
|
||||
class ReceiptAPI:
|
||||
"""
|
||||
Receipt API for accessing receipt information.
|
||||
|
||||
Provides async methods for:
|
||||
- Getting receipt print URL
|
||||
- Getting receipt JSON data
|
||||
|
||||
Maps to PHP Api\\Receipt functionality.
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncHTTPClient, base_endpoint: str, user_inn: str):
|
||||
self.http = http_client
|
||||
self.base_endpoint = base_endpoint
|
||||
self.user_inn = user_inn
|
||||
|
||||
def print_url(self, receipt_uuid: str) -> str:
|
||||
"""
|
||||
Compose receipt print URL.
|
||||
|
||||
Maps to PHP Receipt::printUrl() method.
|
||||
This method composes the URL without making HTTP request.
|
||||
|
||||
Args:
|
||||
receipt_uuid: Receipt UUID
|
||||
|
||||
Returns:
|
||||
Complete URL for receipt printing
|
||||
|
||||
Raises:
|
||||
ValueError: If receipt_uuid is empty
|
||||
"""
|
||||
if not receipt_uuid.strip():
|
||||
raise ValueError("Receipt UUID cannot be empty")
|
||||
|
||||
# Compose URL like PHP: sprintf('/receipt/%s/%s/print', $this->profile->getInn(), $receiptUuid)
|
||||
path = f"/receipt/{self.user_inn}/{receipt_uuid.strip()}/print"
|
||||
return f"{self.base_endpoint}{path}"
|
||||
|
||||
async def json(self, receipt_uuid: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get receipt data in JSON format.
|
||||
|
||||
Maps to PHP Receipt::json() method.
|
||||
|
||||
Args:
|
||||
receipt_uuid: Receipt UUID
|
||||
|
||||
Returns:
|
||||
Dictionary with receipt JSON data
|
||||
|
||||
Raises:
|
||||
ValueError: If receipt_uuid is empty
|
||||
DomainException: For API errors
|
||||
"""
|
||||
if not receipt_uuid.strip():
|
||||
raise ValueError("Receipt UUID cannot be empty")
|
||||
|
||||
# Make GET request like PHP: sprintf('/receipt/%s/%s/json', $this->profile->getInn(), $receiptUuid)
|
||||
path = f"/receipt/{self.user_inn}/{receipt_uuid.strip()}/json"
|
||||
response = await self.http.get(path)
|
||||
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Tax API implementation.
|
||||
Based on PHP library's Api\\Tax class.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ._http import AsyncHTTPClient
|
||||
|
||||
|
||||
class TaxAPI:
|
||||
"""
|
||||
Tax API for tax information and history.
|
||||
|
||||
Provides async methods for:
|
||||
- Getting current tax information
|
||||
- Getting tax history by OKTMO
|
||||
- Getting payment records
|
||||
|
||||
Maps to PHP Api\\Tax functionality.
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncHTTPClient):
|
||||
self.http = http_client
|
||||
|
||||
async def get(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get current tax information.
|
||||
|
||||
Maps to PHP Tax::get().
|
||||
|
||||
Returns:
|
||||
Dictionary with current tax data
|
||||
|
||||
Raises:
|
||||
DomainException: For API errors
|
||||
"""
|
||||
response = await self.http.get("/taxes")
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
|
||||
async def history(self, oktmo: str | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Get tax history.
|
||||
|
||||
Maps to PHP Tax::history().
|
||||
|
||||
Args:
|
||||
oktmo: Optional OKTMO code for filtering
|
||||
|
||||
Returns:
|
||||
Dictionary with tax history records
|
||||
|
||||
Raises:
|
||||
DomainException: For API errors
|
||||
"""
|
||||
request_data = {"oktmo": oktmo}
|
||||
response = await self.http.post("/taxes/history", json_data=request_data)
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
|
||||
async def payments(
|
||||
self, oktmo: str | None = None, only_paid: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get tax payment records.
|
||||
|
||||
Maps to PHP Tax::payments().
|
||||
|
||||
Args:
|
||||
oktmo: Optional OKTMO code for filtering
|
||||
only_paid: If True, return only paid records
|
||||
|
||||
Returns:
|
||||
Dictionary with payment records
|
||||
|
||||
Raises:
|
||||
DomainException: For API errors
|
||||
"""
|
||||
request_data = {
|
||||
"oktmo": oktmo,
|
||||
"onlyPaid": only_paid,
|
||||
}
|
||||
response = await self.http.post("/taxes/payments", json_data=request_data)
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
User API implementation.
|
||||
Based on PHP library's Api\\User class.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ._http import AsyncHTTPClient
|
||||
|
||||
|
||||
class UserAPI:
|
||||
"""
|
||||
User API for user information.
|
||||
|
||||
Provides async methods for:
|
||||
- Getting current user information
|
||||
|
||||
Maps to PHP Api\\User functionality.
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncHTTPClient):
|
||||
self.http = http_client
|
||||
|
||||
async def get(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get current user information.
|
||||
|
||||
Maps to PHP User::get().
|
||||
|
||||
Returns:
|
||||
Dictionary with user profile data including:
|
||||
- id, inn, displayName, email, phone
|
||||
- registration dates, status, restrictions
|
||||
- avatar, receipt settings, etc.
|
||||
|
||||
Raises:
|
||||
DomainException: For API errors
|
||||
"""
|
||||
response = await self.http.get("/user")
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
@@ -209,6 +209,20 @@
|
||||
"ADMIN_MESSAGES_BY_CRITERIA": "🔍 By criteria",
|
||||
"ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 By subscriptions",
|
||||
"ADMIN_MESSAGES_HISTORY": "📋 History",
|
||||
"ADMIN_PINNED_MESSAGE": "📌 Pinned message",
|
||||
"ADMIN_PINNED_MESSAGE_UPDATE": "✏️ Update",
|
||||
"ADMIN_PINNED_MESSAGE_DELETE": "🗑️ Remove and disable",
|
||||
"ADMIN_PINNED_POSITION_BEFORE": "⬆️ Send before menu",
|
||||
"ADMIN_PINNED_POSITION_AFTER": "⬇️ Send after menu",
|
||||
"ADMIN_PINNED_START_EVERY_TIME": "🔁 Show on every /start",
|
||||
"ADMIN_PINNED_START_ONCE": "🚫 Show only once",
|
||||
"ADMIN_PINNED_NO_CONTENT": "❌ Could not read text or media from the message, please try again.",
|
||||
"ADMIN_PINNED_SAVING": "📌 Message saved. Starting broadcast and pinning for users...",
|
||||
"ADMIN_PINNED_UPDATED": "✅ <b>Pinned message updated</b>\n\n👥 Recipients: {total}\n✅ Sent: {sent}\n⚠️ Errors: {failed}",
|
||||
"ADMIN_PINNED_SAVED_ASK_BROADCAST": "📌 <b>Message saved!</b>\n\nChoose how to deliver the message to users:\n\n• <b>Broadcast now</b> — will send and pin for all active users\n• <b>Only on /start</b> — users will see it on next bot launch",
|
||||
"ADMIN_PINNED_SAVED_NO_BROADCAST": "✅ <b>Pinned message saved</b>\n\nNo broadcast performed. Users will see the message on their next /start.",
|
||||
"ADMIN_PINNED_BROADCAST_NOW": "📨 Broadcast now to all",
|
||||
"ADMIN_PINNED_BROADCAST_ON_START": "⏳ Only on /start",
|
||||
"ADMIN_MONITORING": "🔍 Monitoring",
|
||||
"ADMIN_MONITORING_ALL_LOGS": "📋 All logs",
|
||||
"ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Auto-pay settings",
|
||||
@@ -1154,8 +1168,8 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via {platega_name} (cards + SBP)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card ({platega_name})</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
@@ -1506,6 +1520,7 @@
|
||||
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
|
||||
"TRAFFIC_TOPUP_DISABLED": "⚠️ Traffic top-up feature is disabled",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
|
||||
@@ -1605,5 +1620,41 @@
|
||||
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
|
||||
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
|
||||
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist"
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist",
|
||||
"CONTEST_START_ANNOUNCEMENT": "Game starts",
|
||||
"CONTEST_PRIZE": "Prize",
|
||||
"DAYS": "days subscription",
|
||||
"CONTEST_WINNERS": "Winners",
|
||||
"CONTEST_ATTEMPTS": "Attempts/user",
|
||||
"CONTEST_ELIGIBILITY": "Only users with active or trial subscription can participate",
|
||||
"REMINDER": "Reminder",
|
||||
"CONTEST_REMINDER_TEXT": "Don't forget to participate in contests to get bonuses",
|
||||
"MODEM_BUTTON": "📡 Modem",
|
||||
"MODEM_ENABLE_BUTTON": "✅ Connect modem",
|
||||
"MODEM_DISABLE_BUTTON": "❌ Disconnect modem",
|
||||
"MODEM_CONFIRM_BUTTON": "✅ Confirm connection",
|
||||
"MODEM_STATUS_ENABLED": "✅ Connected",
|
||||
"MODEM_STATUS_DISABLED": "❌ Not connected",
|
||||
"MODEM_PAID_ONLY": "⚠️ Modem is only available for paid subscriptions",
|
||||
"MODEM_DISABLED": "⚠️ Modem feature is disabled",
|
||||
"MODEM_ALREADY_ENABLED": "ℹ️ Modem is already connected",
|
||||
"MODEM_NOT_ENABLED": "ℹ️ Modem is not connected",
|
||||
"MODEM_INFO_ENABLED": "📡 <b>Modem</b>\n\nStatus: {status}\n\nModem is connected to your subscription.\nMonthly fee: {price}\n\nNo refunds are provided when disconnecting the modem.",
|
||||
"MODEM_INFO_DISABLED": "📡 <b>Modem</b>\n\nStatus: {status}\n\nConnect a modem to your subscription.\nMonthly fee: {price}\n\nWhen connecting a modem, an additional device will be added.",
|
||||
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Insufficient funds</b>\n\nModem connection cost: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method.",
|
||||
"MODEM_CONFIRM_ENABLE": "📡 <b>Confirm modem connection</b>\n\nCost: {price} (for {months} months)\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?",
|
||||
"MODEM_ENABLED_SUCCESS": "✅ <b>Modem successfully connected!</b>\n\n📡 Modem activated\n📱 Device added for modem\n",
|
||||
"MODEM_CHARGED": "💰 Charged: {amount}",
|
||||
"MODEM_DISABLED_SUCCESS": "✅ <b>Modem disconnected</b>\n\n📡 Modem deactivated\nℹ️ No refunds are provided",
|
||||
"MODEM_PERIOD_INFO": "\n📅 Modem valid until: <b>{end_date}</b> ({days} days)",
|
||||
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Warning!</b> Only <b>{days} days</b> left until subscription ends.\nAfter renewal, modem will need to be paid again!",
|
||||
"MODEM_PERIOD_NOTE": "\nℹ️ Days until subscription ends: <b>{days}</b>\nAfter renewal, modem will need to be paid again.",
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Cost: <s>{base_price}</s> <b>{final_price}</b> (for {months} months)\n🎁 Discount {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Cost: {price} (for {months} months)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Confirm modem connection</b>\n\n{price_text}\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?",
|
||||
|
||||
"ADMIN_USER_RESTRICTIONS": "⚠️ Restrict",
|
||||
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Top-up restricted</b>\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
|
||||
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Subscription purchase/renewal restricted</b>\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
|
||||
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal"
|
||||
}
|
||||
|
||||
@@ -212,6 +212,20 @@
|
||||
"ADMIN_MESSAGES_BY_CRITERIA": "🔍 По критериям",
|
||||
"ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 По подпискам",
|
||||
"ADMIN_MESSAGES_HISTORY": "📋 История",
|
||||
"ADMIN_PINNED_MESSAGE": "📌 Закрепленное сообщение",
|
||||
"ADMIN_PINNED_MESSAGE_UPDATE": "✏️ Обновить",
|
||||
"ADMIN_PINNED_MESSAGE_DELETE": "🗑️ Удалить и отключить",
|
||||
"ADMIN_PINNED_POSITION_BEFORE": "⬆️ Показать перед меню",
|
||||
"ADMIN_PINNED_POSITION_AFTER": "⬇️ Показать после меню",
|
||||
"ADMIN_PINNED_START_EVERY_TIME": "🔁 Показать при каждом /start",
|
||||
"ADMIN_PINNED_START_ONCE": "🚫 Показывать только один раз",
|
||||
"ADMIN_PINNED_NO_CONTENT": "❌ Не удалось прочитать текст или медиа в сообщении, попробуйте снова.",
|
||||
"ADMIN_PINNED_SAVING": "📌 Сообщение сохранено. Начинаю отправку и закрепление у пользователей...",
|
||||
"ADMIN_PINNED_UPDATED": "✅ <b>Закрепленное сообщение обновлено</b>\n\n👥 Получателей: {total}\n✅ Отправлено: {sent}\n⚠️ Ошибок: {failed}",
|
||||
"ADMIN_PINNED_SAVED_ASK_BROADCAST": "📌 <b>Сообщение сохранено!</b>\n\nВыберите, как доставить сообщение пользователям:\n\n• <b>Разослать сейчас</b> — отправит и закрепит у всех активных пользователей\n• <b>Только при /start</b> — пользователи увидят при следующем запуске бота",
|
||||
"ADMIN_PINNED_SAVED_NO_BROADCAST": "✅ <b>Закрепленное сообщение сохранено</b>\n\nРассылка не выполнена. Пользователи увидят сообщение при следующем вводе /start.",
|
||||
"ADMIN_PINNED_BROADCAST_NOW": "📨 Разослать сейчас всем",
|
||||
"ADMIN_PINNED_BROADCAST_ON_START": "⏳ Только при /start",
|
||||
"ADMIN_MONITORING": "🔍 Мониторинг",
|
||||
"ADMIN_MONITORING_ALL_LOGS": "📋 Все логи",
|
||||
"ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Настройки автооплаты",
|
||||
@@ -1169,8 +1183,8 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через {platega_name} (карты + СБП)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта ({platega_name})</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
@@ -1521,6 +1535,7 @@
|
||||
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
|
||||
"TRAFFIC_TOPUP_DISABLED": "⚠️ Функция докупки трафика отключена",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
|
||||
@@ -1620,5 +1635,41 @@
|
||||
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
|
||||
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
|
||||
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Черный список"
|
||||
"ADMIN_USERS_BLACKLIST": "🔐 Черный список",
|
||||
"CONTEST_START_ANNOUNCEMENT": "Стартует игра",
|
||||
"CONTEST_PRIZE": "Приз",
|
||||
"DAYS": "дн. подписки",
|
||||
"CONTEST_WINNERS": "Победителей",
|
||||
"CONTEST_ATTEMPTS": "Попыток/польз",
|
||||
"CONTEST_ELIGIBILITY": "Участвовать могут только с активной или триальной подпиской",
|
||||
"REMINDER": "Напоминание",
|
||||
"CONTEST_REMINDER_TEXT": "Не забудьте участвовать в конкурсах для получения бонусов",
|
||||
"MODEM_BUTTON": "📡 Модем",
|
||||
"MODEM_ENABLE_BUTTON": "✅ Подключить модем",
|
||||
"MODEM_DISABLE_BUTTON": "❌ Отключить модем",
|
||||
"MODEM_CONFIRM_BUTTON": "✅ Подтвердить подключение",
|
||||
"MODEM_STATUS_ENABLED": "✅ Подключен",
|
||||
"MODEM_STATUS_DISABLED": "❌ Не подключен",
|
||||
"MODEM_PAID_ONLY": "⚠️ Модем доступен только для платных подписок",
|
||||
"MODEM_DISABLED": "⚠️ Функция модема отключена",
|
||||
"MODEM_ALREADY_ENABLED": "ℹ️ Модем уже подключен",
|
||||
"MODEM_NOT_ENABLED": "ℹ️ Модем не подключен",
|
||||
"MODEM_INFO_ENABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nМодем подключен к вашей подписке.\nЕжемесячная плата: {price}\n\nПри отключении модема возврат средств не производится.",
|
||||
"MODEM_INFO_DISABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nПодключите модем к вашей подписке.\nЕжемесячная плата: {price}\n\nПри подключении модема будет добавлено дополнительное устройство.",
|
||||
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость подключения модема: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения.",
|
||||
"MODEM_CONFIRM_ENABLE": "📡 <b>Подтверждение подключения модема</b>\n\nСтоимость: {price} (за {months} мес)\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?",
|
||||
"MODEM_ENABLED_SUCCESS": "✅ <b>Модем успешно подключен!</b>\n\n📡 Модем активирован\n📱 Добавлено устройство для модема\n",
|
||||
"MODEM_CHARGED": "💰 Списано: {amount}",
|
||||
"MODEM_DISABLED_SUCCESS": "✅ <b>Модем отключен</b>\n\n📡 Модем деактивирован\nℹ️ Возврат средств не производится",
|
||||
"MODEM_PERIOD_INFO": "\n📅 Модем действует до: <b>{end_date}</b> ({days} дн.)",
|
||||
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\nПосле продления подписки модем нужно будет оплатить заново!",
|
||||
"MODEM_PERIOD_NOTE": "\nℹ️ До окончания подписки: <b>{days} дн.</b>\nПосле продления модем нужно будет оплатить заново.",
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n🎁 Скидка {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Стоимость: {price} (за {months} мес)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Подтверждение подключения модема</b>\n\n{price_text}\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?",
|
||||
|
||||
"ADMIN_USER_RESTRICTIONS": "⚠️ Ограничить",
|
||||
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Покупка/продление подписки ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
|
||||
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать"
|
||||
}
|
||||
|
||||
@@ -138,8 +138,22 @@
|
||||
"ADMIN_MESSAGES_ALL_USERS": "📨 Всім користувачам",
|
||||
"ADMIN_MESSAGES_BY_CRITERIA": "🔍 За критеріями",
|
||||
"ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 За підписками",
|
||||
"ADMIN_MESSAGES_HISTORY": "📋 Історія",
|
||||
"ADMIN_MONITORING": "🔍 Моніторинг",
|
||||
"ADMIN_MESSAGES_HISTORY": "📋 Історія",
|
||||
"ADMIN_PINNED_MESSAGE": "📌 Закріплене повідомлення",
|
||||
"ADMIN_PINNED_MESSAGE_UPDATE": "✏️ Оновити",
|
||||
"ADMIN_PINNED_MESSAGE_DELETE": "🗑️ Видалити та вимкнути",
|
||||
"ADMIN_PINNED_POSITION_BEFORE": "⬆️ Показати перед меню",
|
||||
"ADMIN_PINNED_POSITION_AFTER": "⬇️ Показати після меню",
|
||||
"ADMIN_PINNED_START_EVERY_TIME": "🔁 Показувати при кожному /start",
|
||||
"ADMIN_PINNED_START_ONCE": "🚫 Показувати лише один раз",
|
||||
"ADMIN_PINNED_NO_CONTENT": "❌ Не вдалося прочитати текст або медіа у повідомленні, спробуйте ще раз.",
|
||||
"ADMIN_PINNED_SAVING": "📌 Повідомлення збережено. Починаю відправку та закріплення у користувачів...",
|
||||
"ADMIN_PINNED_UPDATED": "✅ <b>Закріплене повідомлення оновлено</b>\n\n👥 Отримувачів: {total}\n✅ Надіслано: {sent}\n⚠️ Помилок: {failed}",
|
||||
"ADMIN_PINNED_SAVED_ASK_BROADCAST": "📌 <b>Повідомлення збережено!</b>\n\nОберіть, як доставити повідомлення користувачам:\n\n• <b>Розіслати зараз</b> — відправить і закріпить у всіх активних користувачів\n• <b>Тільки при /start</b> — користувачі побачать при наступному запуску бота",
|
||||
"ADMIN_PINNED_SAVED_NO_BROADCAST": "✅ <b>Закріплене повідомлення збережено</b>\n\nРозсилка не виконана. Користувачі побачать повідомлення при наступному введенні /start.",
|
||||
"ADMIN_PINNED_BROADCAST_NOW": "📨 Розіслати зараз всім",
|
||||
"ADMIN_PINNED_BROADCAST_ON_START": "⏳ Тільки при /start",
|
||||
"ADMIN_MONITORING": "🔍 Моніторинг",
|
||||
"ADMIN_MONITORING_ALL_LOGS": "📋 Всі логи",
|
||||
"ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Налаштування автооплати",
|
||||
"ADMIN_MONITORING_AUTO_CLEANUP": "🧹 Автоочищення логів",
|
||||
@@ -1095,8 +1109,8 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банківська картка ({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (картки + СБП)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банківська картка (Platega)</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через {platega_name} (картки + СБП)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банківська картка ({platega_name})</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "швидко та зручно",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи",
|
||||
@@ -1440,6 +1454,7 @@
|
||||
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас вже безлімітний трафік",
|
||||
"TRAFFIC_FIXED_MODE": "⚠️ У поточному режимі трафік фіксований і не може бути змінений",
|
||||
"TRAFFIC_TOPUP_DISABLED": "⚠️ Функція докупки трафіку вимкнена",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостатньо коштів!\nПотрібно: {required} (за {months} міс)\nУ вас: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Ліміт трафіку не змінився",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакети трафіку не налаштовані",
|
||||
@@ -1537,5 +1552,28 @@
|
||||
"POLL_EMPTY": "Опитування поки недоступне.",
|
||||
"POLL_ERROR": "Не вдалося обробити опитування. Спробуйте пізніше.",
|
||||
"POLL_COMPLETED": "🙏 Дякуємо за участь в опитуванні!",
|
||||
"POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс."
|
||||
"POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс.",
|
||||
"MODEM_BUTTON": "📡 Модем",
|
||||
"MODEM_ENABLE_BUTTON": "✅ Підключити модем",
|
||||
"MODEM_DISABLE_BUTTON": "❌ Відключити модем",
|
||||
"MODEM_CONFIRM_BUTTON": "✅ Підтвердити підключення",
|
||||
"MODEM_STATUS_ENABLED": "✅ Підключено",
|
||||
"MODEM_STATUS_DISABLED": "❌ Не підключено",
|
||||
"MODEM_PAID_ONLY": "⚠️ Модем доступний тільки для платних підписок",
|
||||
"MODEM_DISABLED": "⚠️ Функція модема вимкнена",
|
||||
"MODEM_ALREADY_ENABLED": "ℹ️ Модем вже підключено",
|
||||
"MODEM_NOT_ENABLED": "ℹ️ Модем не підключено",
|
||||
"MODEM_INFO_ENABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nМодем підключено до вашої підписки.\nЩомісячна плата: {price}\n\nПри відключенні модема повернення коштів не здійснюється.",
|
||||
"MODEM_INFO_DISABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nПідключіть модем до вашої підписки.\nЩомісячна плата: {price}\n\nПри підключенні модема буде додано додатковий пристрій.",
|
||||
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Недостатньо коштів</b>\n\nВартість підключення модема: {required}\nНа балансі: {balance}\nНе вистачає: {missing}\n\nОберіть спосіб поповнення.",
|
||||
"MODEM_CONFIRM_ENABLE": "📡 <b>Підтвердження підключення модема</b>\n\nВартість: {price} (за {months} міс)\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
|
||||
"MODEM_ENABLED_SUCCESS": "✅ <b>Модем успішно підключено!</b>\n\n📡 Модем активовано\n📱 Додано пристрій для модема\n",
|
||||
"MODEM_CHARGED": "💰 Списано: {amount}",
|
||||
"MODEM_DISABLED_SUCCESS": "✅ <b>Модем відключено</b>\n\n📡 Модем деактивовано\nℹ️ Повернення коштів не здійснюється",
|
||||
"MODEM_PERIOD_INFO": "\n📅 Модем діє до: <b>{end_date}</b> ({days} дн.)",
|
||||
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Увага!</b> До закінчення підписки залишилось лише <b>{days} дн.</b>\nПісля продовження підписки модем потрібно буде оплатити знову!",
|
||||
"MODEM_PERIOD_NOTE": "\nℹ️ До закінчення підписки: <b>{days} дн.</b>\nПісля продовження модем потрібно буде оплатити знову.",
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: <s>{base_price}</s> <b>{final_price}</b> (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?"
|
||||
}
|
||||
|
||||
@@ -138,6 +138,20 @@
|
||||
"ADMIN_MESSAGES_BY_CRITERIA":"🔍按条件",
|
||||
"ADMIN_MESSAGES_BY_SUBSCRIPTIONS":"🎯按订阅",
|
||||
"ADMIN_MESSAGES_HISTORY":"📋历史记录",
|
||||
"ADMIN_PINNED_MESSAGE":"📌置顶消息",
|
||||
"ADMIN_PINNED_MESSAGE_UPDATE":"✏️更新",
|
||||
"ADMIN_PINNED_MESSAGE_DELETE":"🗑️删除并停用",
|
||||
"ADMIN_PINNED_POSITION_BEFORE":"⬆️菜单前发送",
|
||||
"ADMIN_PINNED_POSITION_AFTER":"⬇️菜单后发送",
|
||||
"ADMIN_PINNED_START_EVERY_TIME":"🔁 每次 /start 时发送",
|
||||
"ADMIN_PINNED_START_ONCE":"🚫 仅发送一次",
|
||||
"ADMIN_PINNED_NO_CONTENT":"❌ 无法读取消息中的文本或媒体,请重试。",
|
||||
"ADMIN_PINNED_SAVING":"📌 消息已保存。开始向用户发送并置顶...",
|
||||
"ADMIN_PINNED_UPDATED":"✅ <b>置顶消息已更新</b>\n\n👥 收件人: {total}\n✅ 已发送: {sent}\n⚠️ 错误: {failed}",
|
||||
"ADMIN_PINNED_SAVED_ASK_BROADCAST":"📌 <b>消息已保存!</b>\n\n选择如何向用户发送消息:\n\n• <b>立即广播</b> — 将发送并置顶给所有活跃用户\n• <b>仅在 /start 时</b> — 用户将在下次启动机器人时看到",
|
||||
"ADMIN_PINNED_SAVED_NO_BROADCAST":"✅ <b>置顶消息已保存</b>\n\n未执行广播。用户将在下次输入 /start 时看到消息。",
|
||||
"ADMIN_PINNED_BROADCAST_NOW":"📨 立即广播给所有人",
|
||||
"ADMIN_PINNED_BROADCAST_ON_START":"⏳ 仅在 /start 时",
|
||||
"ADMIN_MONITORING":"🔍监控",
|
||||
"ADMIN_MONITORING_ALL_LOGS":"📋所有日志",
|
||||
"ADMIN_MONITORING_AUTOPAY_SETTINGS":"💳自动支付设置",
|
||||
@@ -1093,8 +1107,8 @@
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME":"🪙<b>加密货币</b>",
|
||||
"PAYMENT_METHOD_MULENPAY_DESCRIPTION":"通过{mulenpay_name}",
|
||||
"PAYMENT_METHOD_MULENPAY_NAME":"💳<b>银行卡({mulenpay_name})</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION":"通过Platega(银行卡+SBP)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME":"💳<b>银行卡(Platega)</b>",
|
||||
"PAYMENT_METHOD_PLATEGA_DESCRIPTION":"通过{platega_name}(银行卡+SBP)",
|
||||
"PAYMENT_METHOD_PLATEGA_NAME":"💳<b>银行卡({platega_name})</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION":"快速便捷",
|
||||
"PAYMENT_METHOD_STARS_NAME":"⭐<b>TelegramStars</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION":"其他方式",
|
||||
@@ -1438,6 +1452,7 @@
|
||||
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
|
||||
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
|
||||
"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
|
||||
"TRAFFIC_NO_CHANGE":"ℹ️流量限制未更改",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
|
||||
@@ -1765,6 +1780,7 @@
|
||||
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
|
||||
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
|
||||
"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
|
||||
"TRAFFIC_NO_CHANGE":"ℹ️流量限制未更改",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
|
||||
@@ -1864,5 +1880,28 @@
|
||||
"POLL_COMPLETED":"🙏感谢您参与民意调查!",
|
||||
"POLL_REWARD_GRANTED":"奖励{amount}已存入您的余额。",
|
||||
"DEVICE_GUIDE_WINDOWS":"💻Windows",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前"
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前",
|
||||
"MODEM_BUTTON":"📡 调制解调器",
|
||||
"MODEM_ENABLE_BUTTON":"✅ 连接调制解调器",
|
||||
"MODEM_DISABLE_BUTTON":"❌ 断开调制解调器",
|
||||
"MODEM_CONFIRM_BUTTON":"✅ 确认连接",
|
||||
"MODEM_STATUS_ENABLED":"✅ 已连接",
|
||||
"MODEM_STATUS_DISABLED":"❌ 未连接",
|
||||
"MODEM_PAID_ONLY":"⚠️ 调制解调器仅适用于付费订阅",
|
||||
"MODEM_DISABLED":"⚠️ 调制解调器功能已禁用",
|
||||
"MODEM_ALREADY_ENABLED":"ℹ️ 调制解调器已连接",
|
||||
"MODEM_NOT_ENABLED":"ℹ️ 调制解调器未连接",
|
||||
"MODEM_INFO_ENABLED":"📡 <b>调制解调器</b>\n\n状态:{status}\n\n调制解调器已连接到您的订阅。\n月费:{price}\n\n断开调制解调器时不提供退款。",
|
||||
"MODEM_INFO_DISABLED":"📡 <b>调制解调器</b>\n\n状态:{status}\n\n将调制解调器连接到您的订阅。\n月费:{price}\n\n连接调制解调器时将添加额外设备。",
|
||||
"MODEM_INSUFFICIENT_FUNDS":"⚠️ <b>余额不足</b>\n\n调制解调器连接费用:{required}\n余额:{balance}\n差额:{missing}\n\n请选择充值方式。",
|
||||
"MODEM_CONFIRM_ENABLE":"📡 <b>确认连接调制解调器</b>\n\n费用:{price}({months}个月)\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
|
||||
"MODEM_ENABLED_SUCCESS":"✅ <b>调制解调器连接成功!</b>\n\n📡 调制解调器已激活\n📱 已添加调制解调器设备\n",
|
||||
"MODEM_CHARGED":"💰 已扣款:{amount}",
|
||||
"MODEM_DISABLED_SUCCESS":"✅ <b>调制解调器已断开</b>\n\n📡 调制解调器已停用\nℹ️ 不提供退款",
|
||||
"MODEM_PERIOD_INFO":"\n📅 调制解调器有效期至:<b>{end_date}</b>({days}天)",
|
||||
"MODEM_SHORT_PERIOD_WARNING":"\n⚠️ <b>注意!</b>距离订阅结束仅剩<b>{days}天</b>。\n续订后需重新支付调制解调器费用!",
|
||||
"MODEM_PERIOD_NOTE":"\nℹ️ 距离订阅结束:<b>{days}天</b>\n续订后需重新支付调制解调器费用。",
|
||||
"MODEM_PRICE_WITH_DISCOUNT":"费用:<s>{base_price}</s> <b>{final_price}</b>({months}个月)\n🎁 折扣{discount}%:-{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT":"费用:{price}({months}个月)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE":"📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?"
|
||||
}
|
||||
|
||||
@@ -108,8 +108,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
elif member.status in self.BAD_MEMBER_STATUS:
|
||||
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
|
||||
|
||||
if telegram_id and settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
||||
await self._deactivate_trial_subscription(telegram_id)
|
||||
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
|
||||
await self._deactivate_subscription_on_unsubscribe(telegram_id)
|
||||
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
|
||||
@@ -253,8 +253,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
finally:
|
||||
break
|
||||
|
||||
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
||||
async def _deactivate_subscription_on_unsubscribe(self, telegram_id: int) -> None:
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
logger.debug(
|
||||
"ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено",
|
||||
telegram_id,
|
||||
@@ -272,19 +272,29 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
break
|
||||
|
||||
subscription = user.subscription
|
||||
if (not subscription.is_trial or
|
||||
subscription.status != SubscriptionStatus.ACTIVE.value):
|
||||
|
||||
if subscription.status != SubscriptionStatus.ACTIVE.value:
|
||||
logger.debug(
|
||||
"ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)",
|
||||
"ℹ️ Подписка пользователя %s не активна (status=%s) — пропускаем деактивацию",
|
||||
telegram_id,
|
||||
subscription.is_trial,
|
||||
subscription.status,
|
||||
)
|
||||
break
|
||||
|
||||
if settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
pass
|
||||
elif not subscription.is_trial:
|
||||
logger.debug(
|
||||
"ℹ️ Подписка пользователя %s платная, CHANNEL_REQUIRED_FOR_ALL=False — пропускаем деактивацию",
|
||||
telegram_id,
|
||||
)
|
||||
break
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
sub_type = "Триальная" if subscription.is_trial else "Платная"
|
||||
logger.info(
|
||||
"🚫 Триальная подписка пользователя %s отключена после отписки от канала",
|
||||
"🚫 %s подписка пользователя %s отключена после отписки от канала",
|
||||
sub_type,
|
||||
telegram_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -1510,6 +1510,7 @@ class AdminNotificationService:
|
||||
except Exception:
|
||||
runtime_enabled = True
|
||||
if not (self._is_enabled() and runtime_enabled):
|
||||
logger.info(f"Ticket notification skipped: _is_enabled={self._is_enabled()}, runtime_enabled={runtime_enabled}")
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import aiofiles
|
||||
import pyzipper
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy import inspect, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -1534,13 +1535,24 @@ class BackupService:
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
password = settings.get_backup_archive_password()
|
||||
file_to_send = file_path
|
||||
temp_zip_path = None
|
||||
|
||||
if password:
|
||||
temp_zip_path = await self._create_password_protected_archive(file_path, password)
|
||||
if temp_zip_path:
|
||||
file_to_send = temp_zip_path
|
||||
|
||||
caption = f"📦 <b>Резервная копия</b>\n\n"
|
||||
if temp_zip_path:
|
||||
caption += f"🔐 <b>Архив защищён паролем</b>\n\n"
|
||||
caption += f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
|
||||
send_kwargs = {
|
||||
'chat_id': chat_id,
|
||||
'document': FSInputFile(file_path),
|
||||
'caption': (
|
||||
f"📦 <b>Резервная копия</b>\n\n"
|
||||
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
|
||||
),
|
||||
'document': FSInputFile(file_to_send),
|
||||
'caption': caption,
|
||||
'parse_mode': 'HTML'
|
||||
}
|
||||
|
||||
@@ -1549,8 +1561,43 @@ class BackupService:
|
||||
|
||||
await self.bot.send_document(**send_kwargs)
|
||||
logger.info(f"Бекап отправлен в чат {chat_id}")
|
||||
|
||||
if temp_zip_path and Path(temp_zip_path).exists():
|
||||
try:
|
||||
Path(temp_zip_path).unlink()
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"Не удалось удалить временный архив: {cleanup_error}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки бекапа в чат: {e}")
|
||||
|
||||
async def _create_password_protected_archive(self, file_path: str, password: str) -> Optional[str]:
|
||||
try:
|
||||
source_path = Path(file_path)
|
||||
if not source_path.exists():
|
||||
logger.error(f"Исходный файл бекапа не найден: {file_path}")
|
||||
return None
|
||||
|
||||
zip_filename = source_path.stem + ".zip"
|
||||
zip_path = source_path.parent / zip_filename
|
||||
|
||||
def create_zip():
|
||||
with pyzipper.AESZipFile(
|
||||
zip_path,
|
||||
'w',
|
||||
compression=pyzipper.ZIP_DEFLATED,
|
||||
encryption=pyzipper.WZ_AES
|
||||
) as zf:
|
||||
zf.setpassword(password.encode('utf-8'))
|
||||
zf.write(source_path, arcname=source_path.name)
|
||||
|
||||
await asyncio.to_thread(create_zip)
|
||||
logger.info(f"Создан защищённый паролем архив: {zip_path}")
|
||||
return str(zip_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания защищённого архива: {e}")
|
||||
return None
|
||||
|
||||
|
||||
backup_service = BackupService()
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.crud.campaign import record_campaign_registration
|
||||
from app.database.crud.subscription import (
|
||||
create_paid_subscription,
|
||||
create_trial_subscription,
|
||||
get_subscription_by_user_id,
|
||||
)
|
||||
from app.database.crud.user import add_user_balance
|
||||
@@ -139,7 +139,9 @@ class AdvertisingCampaignService:
|
||||
error,
|
||||
)
|
||||
|
||||
new_subscription = await create_paid_subscription(
|
||||
squad_uuid = squads[0] if squads else None
|
||||
|
||||
new_subscription = await create_trial_subscription(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
duration_days=duration_days,
|
||||
@@ -147,6 +149,8 @@ class AdvertisingCampaignService:
|
||||
device_limit=device_limit,
|
||||
connected_squads=squads,
|
||||
update_server_counters=True,
|
||||
is_trial=True,
|
||||
squad_uuid=squad_uuid,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""High level service for interacting with CloudPayments API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudPaymentsAPIError(RuntimeError):
|
||||
"""Raised when the CloudPayments API returns an error response."""
|
||||
|
||||
def __init__(self, message: str, reason_code: Optional[int] = None):
|
||||
super().__init__(message)
|
||||
self.reason_code = reason_code
|
||||
|
||||
|
||||
class CloudPaymentsService:
|
||||
"""Wrapper around the CloudPayments REST API for balance top-ups."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
public_id: Optional[str] = None,
|
||||
api_secret: Optional[str] = None,
|
||||
api_url: Optional[str] = None,
|
||||
) -> None:
|
||||
self.public_id = public_id or settings.CLOUDPAYMENTS_PUBLIC_ID
|
||||
self.api_secret = api_secret or settings.CLOUDPAYMENTS_API_SECRET
|
||||
self.api_url = (api_url or settings.CLOUDPAYMENTS_API_URL).rstrip("/")
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(
|
||||
settings.is_cloudpayments_enabled()
|
||||
and self.public_id
|
||||
and self.api_secret
|
||||
)
|
||||
|
||||
def _get_auth_header(self) -> str:
|
||||
"""Generate Basic Auth header for CloudPayments API."""
|
||||
if not self.public_id or not self.api_secret:
|
||||
raise CloudPaymentsAPIError("CloudPayments credentials not configured")
|
||||
credentials = f"{self.public_id}:{self.api_secret}"
|
||||
encoded = base64.b64encode(credentials.encode()).decode()
|
||||
return f"Basic {encoded}"
|
||||
|
||||
def _build_headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Authorization": self._get_auth_header(),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make a request to CloudPayments API."""
|
||||
if not self.is_configured:
|
||||
raise CloudPaymentsAPIError("CloudPayments service is not configured")
|
||||
|
||||
url = f"{self.api_url}/{path.lstrip('/')}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
json=json,
|
||||
headers=self._build_headers(),
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"CloudPayments API error %s: %s", response.status_code, data
|
||||
)
|
||||
raise CloudPaymentsAPIError(
|
||||
f"CloudPayments API returned status {response.status_code}"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
except httpx.RequestError as error:
|
||||
logger.error("Error communicating with CloudPayments API: %s", error)
|
||||
raise CloudPaymentsAPIError(
|
||||
"Failed to communicate with CloudPayments API"
|
||||
) from error
|
||||
|
||||
@staticmethod
|
||||
def _amount_from_kopeks(amount_kopeks: int) -> float:
|
||||
"""Convert kopeks to rubles."""
|
||||
return amount_kopeks / 100
|
||||
|
||||
@staticmethod
|
||||
def _amount_to_kopeks(amount: float) -> int:
|
||||
"""Convert rubles to kopeks."""
|
||||
return int(amount * 100)
|
||||
|
||||
async def generate_payment_link(
|
||||
self,
|
||||
telegram_id: int,
|
||||
amount_kopeks: int,
|
||||
invoice_id: str,
|
||||
description: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
success_redirect_url: Optional[str] = None,
|
||||
fail_redirect_url: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a payment order via CloudPayments API and return payment URL.
|
||||
|
||||
Args:
|
||||
telegram_id: User's Telegram ID (will be used as AccountId)
|
||||
amount_kopeks: Amount in kopeks
|
||||
invoice_id: Unique invoice ID for this payment
|
||||
description: Payment description
|
||||
email: User's email (optional)
|
||||
success_redirect_url: Redirect URL after successful payment
|
||||
fail_redirect_url: Redirect URL after failed payment
|
||||
|
||||
Returns:
|
||||
URL to CloudPayments payment page
|
||||
"""
|
||||
if not self.is_configured:
|
||||
raise CloudPaymentsAPIError("CloudPayments is not configured")
|
||||
|
||||
amount = self._amount_from_kopeks(amount_kopeks)
|
||||
|
||||
# Формируем данные для создания заказа через API /orders/create
|
||||
payload: Dict[str, Any] = {
|
||||
"Amount": amount,
|
||||
"Currency": settings.CLOUDPAYMENTS_CURRENCY,
|
||||
"Description": description or settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
"AccountId": str(telegram_id),
|
||||
"InvoiceId": invoice_id,
|
||||
"JsonData": {
|
||||
"telegram_id": telegram_id,
|
||||
"invoice_id": invoice_id,
|
||||
},
|
||||
}
|
||||
|
||||
if email:
|
||||
payload["Email"] = email
|
||||
|
||||
if settings.CLOUDPAYMENTS_REQUIRE_EMAIL:
|
||||
payload["RequireConfirmation"] = False
|
||||
|
||||
# URL для редиректа после оплаты
|
||||
if success_redirect_url or settings.CLOUDPAYMENTS_RETURN_URL:
|
||||
payload["SuccessRedirectUrl"] = success_redirect_url or settings.CLOUDPAYMENTS_RETURN_URL
|
||||
|
||||
if fail_redirect_url:
|
||||
payload["FailRedirectUrl"] = fail_redirect_url
|
||||
|
||||
# Создаём заказ через API
|
||||
response = await self._request("POST", "/orders/create", json=payload)
|
||||
|
||||
if not response.get("Success"):
|
||||
error_message = response.get("Message", "Unknown error")
|
||||
logger.error("CloudPayments orders/create failed: %s", error_message)
|
||||
raise CloudPaymentsAPIError(f"Failed to create order: {error_message}")
|
||||
|
||||
model = response.get("Model", {})
|
||||
payment_url = model.get("Url")
|
||||
|
||||
if not payment_url:
|
||||
logger.error("CloudPayments orders/create returned no URL: %s", response)
|
||||
raise CloudPaymentsAPIError("CloudPayments API returned no payment URL")
|
||||
|
||||
logger.info(
|
||||
"CloudPayments order created: id=%s, url=%s",
|
||||
model.get("Id"),
|
||||
payment_url,
|
||||
)
|
||||
|
||||
return payment_url
|
||||
|
||||
def generate_invoice_id(self, telegram_id: int) -> str:
|
||||
"""Generate unique invoice ID for a payment."""
|
||||
return f"cp_{telegram_id}_{int(time.time())}"
|
||||
|
||||
async def charge_by_token(
|
||||
self,
|
||||
token: str,
|
||||
amount_kopeks: int,
|
||||
account_id: str,
|
||||
invoice_id: str,
|
||||
description: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Charge a payment using saved card token (recurrent payment).
|
||||
|
||||
Args:
|
||||
token: Card token from previous payment
|
||||
amount_kopeks: Amount in kopeks
|
||||
account_id: User's account ID (telegram_id)
|
||||
invoice_id: Unique invoice ID
|
||||
description: Payment description
|
||||
|
||||
Returns:
|
||||
CloudPayments API response
|
||||
"""
|
||||
amount = self._amount_from_kopeks(amount_kopeks)
|
||||
|
||||
payload = {
|
||||
"Amount": amount,
|
||||
"Currency": settings.CLOUDPAYMENTS_CURRENCY,
|
||||
"AccountId": account_id,
|
||||
"Token": token,
|
||||
"InvoiceId": invoice_id,
|
||||
"Description": description or settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
}
|
||||
|
||||
return await self._request("POST", "/payments/tokens/charge", json=payload)
|
||||
|
||||
async def get_payment(self, transaction_id: int) -> Dict[str, Any]:
|
||||
"""Get payment details by CloudPayments transaction ID."""
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/payments/get",
|
||||
json={"TransactionId": transaction_id},
|
||||
)
|
||||
|
||||
async def find_payment(self, invoice_id: str) -> Dict[str, Any]:
|
||||
"""Find payment by invoice ID."""
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/payments/find",
|
||||
json={"InvoiceId": invoice_id},
|
||||
)
|
||||
|
||||
async def refund_payment(
|
||||
self,
|
||||
transaction_id: int,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Refund a payment (full or partial).
|
||||
|
||||
Args:
|
||||
transaction_id: CloudPayments transaction ID
|
||||
amount_kopeks: Amount to refund in kopeks (None for full refund)
|
||||
|
||||
Returns:
|
||||
CloudPayments API response
|
||||
"""
|
||||
payload: Dict[str, Any] = {"TransactionId": transaction_id}
|
||||
|
||||
if amount_kopeks is not None:
|
||||
payload["Amount"] = self._amount_from_kopeks(amount_kopeks)
|
||||
|
||||
return await self._request("POST", "/payments/refund", json=payload)
|
||||
|
||||
async def void_payment(self, transaction_id: int) -> Dict[str, Any]:
|
||||
"""Cancel an authorized but not captured payment."""
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/payments/void",
|
||||
json={"TransactionId": transaction_id},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def verify_webhook_signature(body: bytes, signature: str, api_secret: str) -> bool:
|
||||
"""
|
||||
Verify CloudPayments webhook signature.
|
||||
|
||||
Args:
|
||||
body: Raw request body bytes
|
||||
signature: Signature from X-Content-HMAC header
|
||||
api_secret: CloudPayments API secret
|
||||
|
||||
Returns:
|
||||
True if signature is valid
|
||||
"""
|
||||
if not signature or not api_secret:
|
||||
return False
|
||||
|
||||
calculated = base64.b64encode(
|
||||
hmac.new(
|
||||
api_secret.encode(),
|
||||
body,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
return hmac.compare_digest(calculated, signature)
|
||||
|
||||
@staticmethod
|
||||
def parse_webhook_data(form_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse webhook form data into structured format.
|
||||
|
||||
Args:
|
||||
form_data: Form data from webhook request
|
||||
|
||||
Returns:
|
||||
Parsed payment data
|
||||
"""
|
||||
return {
|
||||
"transaction_id": int(form_data.get("TransactionId", 0)),
|
||||
"amount": float(form_data.get("Amount", 0)),
|
||||
"currency": form_data.get("Currency", "RUB"),
|
||||
"invoice_id": form_data.get("InvoiceId", ""),
|
||||
"account_id": form_data.get("AccountId", ""),
|
||||
"token": form_data.get("Token"),
|
||||
"card_first_six": form_data.get("CardFirstSix"),
|
||||
"card_last_four": form_data.get("CardLastFour"),
|
||||
"card_type": form_data.get("CardType"),
|
||||
"card_exp_date": form_data.get("CardExpDate"),
|
||||
"email": form_data.get("Email"),
|
||||
"status": form_data.get("Status", ""),
|
||||
"test_mode": form_data.get("TestMode") == "1" or form_data.get("TestMode") == "True",
|
||||
"reason": form_data.get("Reason"),
|
||||
"reason_code": int(form_data.get("ReasonCode", 0)) if form_data.get("ReasonCode") else None,
|
||||
"card_holder_message": form_data.get("CardHolderMessage"),
|
||||
"data": form_data.get("Data"), # JSON string with custom data
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta, time, timezone
|
||||
from typing import Dict, List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
@@ -18,17 +17,19 @@ from app.database.crud.contest import (
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import ContestTemplate, SubscriptionStatus, User
|
||||
from app.services.contests.enums import GameType, PrizeType, RoundStatus
|
||||
from app.services.contests.games import get_game_strategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Slugs for games
|
||||
GAME_QUEST = "quest_buttons"
|
||||
GAME_LOCKS = "lock_hack"
|
||||
GAME_CIPHER = "letter_cipher"
|
||||
GAME_SERVER = "server_lottery"
|
||||
GAME_BLITZ = "blitz_reaction"
|
||||
GAME_EMOJI = "emoji_guess"
|
||||
GAME_ANAGRAM = "anagram"
|
||||
# Legacy aliases for backward compatibility
|
||||
GAME_QUEST = GameType.QUEST_BUTTONS.value
|
||||
GAME_LOCKS = GameType.LOCK_HACK.value
|
||||
GAME_CIPHER = GameType.LETTER_CIPHER.value
|
||||
GAME_SERVER = GameType.SERVER_LOTTERY.value
|
||||
GAME_BLITZ = GameType.BLITZ_REACTION.value
|
||||
GAME_EMOJI = GameType.EMOJI_GUESS.value
|
||||
GAME_ANAGRAM = GameType.ANAGRAM.value
|
||||
|
||||
|
||||
DEFAULT_TEMPLATES = [
|
||||
@@ -36,7 +37,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_QUEST,
|
||||
"name": "Квест-кнопки",
|
||||
"description": "Найди секретную кнопку 3×3",
|
||||
"prize_days": 1,
|
||||
"prize_type": "days",
|
||||
"prize_value": "1",
|
||||
"max_winners": 3,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
@@ -48,7 +50,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_LOCKS,
|
||||
"name": "Кнопочный взлом",
|
||||
"description": "Найди взломанную кнопку среди 20 замков",
|
||||
"prize_days": 5,
|
||||
"prize_type": "days",
|
||||
"prize_value": "5",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
@@ -60,7 +63,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_CIPHER,
|
||||
"name": "Шифр букв",
|
||||
"description": "Расшифруй слово по номерам",
|
||||
"prize_days": 1,
|
||||
"prize_type": "days",
|
||||
"prize_value": "1",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
@@ -72,7 +76,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_SERVER,
|
||||
"name": "Сервер-лотерея",
|
||||
"description": "Угадай доступный сервер",
|
||||
"prize_days": 7,
|
||||
"prize_type": "days",
|
||||
"prize_value": "7",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
@@ -84,7 +89,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_BLITZ,
|
||||
"name": "Блиц-реакция",
|
||||
"description": "Нажми кнопку за 10 секунд",
|
||||
"prize_days": 1,
|
||||
"prize_type": "days",
|
||||
"prize_value": "1",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 2,
|
||||
@@ -96,7 +102,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_EMOJI,
|
||||
"name": "Угадай сервис по эмодзи",
|
||||
"description": "Определи сервис по эмодзи",
|
||||
"prize_days": 1,
|
||||
"prize_type": "days",
|
||||
"prize_value": "1",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
@@ -108,7 +115,8 @@ DEFAULT_TEMPLATES = [
|
||||
"slug": GAME_ANAGRAM,
|
||||
"name": "Анаграмма дня",
|
||||
"description": "Собери слово из букв",
|
||||
"prize_days": 1,
|
||||
"prize_type": "days",
|
||||
"prize_value": "1",
|
||||
"max_winners": 1,
|
||||
"attempts_per_user": 1,
|
||||
"times_per_day": 1,
|
||||
@@ -239,38 +247,12 @@ class ContestRotationService:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict:
|
||||
payload = tpl.payload or {}
|
||||
if tpl.slug == GAME_QUEST:
|
||||
rows = payload.get("rows", 3)
|
||||
cols = payload.get("cols", 3)
|
||||
total = rows * cols
|
||||
secret_idx = random.randint(0, total - 1)
|
||||
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_LOCKS:
|
||||
total = payload.get("buttons", 20)
|
||||
secret_idx = random.randint(0, max(0, total - 1))
|
||||
return {"total": total, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_CIPHER:
|
||||
words = payload.get("words") or ["VPN"]
|
||||
word = random.choice(words)
|
||||
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
|
||||
return {"question": "-".join(codes), "answer": word.upper()}
|
||||
if tpl.slug == GAME_SERVER:
|
||||
flags = payload.get("flags") or ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]
|
||||
secret_idx = random.randint(0, len(flags) - 1)
|
||||
return {"flags": flags, "secret_idx": secret_idx}
|
||||
if tpl.slug == GAME_BLITZ:
|
||||
return {"timeout_seconds": payload.get("timeout_seconds", 10)}
|
||||
if tpl.slug == GAME_EMOJI:
|
||||
pairs = payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
|
||||
pair = random.choice(pairs)
|
||||
return pair
|
||||
if tpl.slug == GAME_ANAGRAM:
|
||||
words = payload.get("words") or ["SERVER"]
|
||||
word = random.choice(words).upper()
|
||||
shuffled = "".join(random.sample(word, len(word)))
|
||||
return {"letters": shuffled, "answer": word}
|
||||
return payload
|
||||
"""Build round-specific payload using game strategy."""
|
||||
strategy = get_game_strategy(tpl.slug)
|
||||
if strategy:
|
||||
return strategy.build_payload(tpl.payload or {})
|
||||
# Fallback for unknown game types
|
||||
return tpl.payload or {}
|
||||
|
||||
async def _announce_round_start(
|
||||
self,
|
||||
@@ -281,11 +263,28 @@ class ContestRotationService:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts("ru") # Default to ru for announcements
|
||||
|
||||
# Format prize display based on prize_type
|
||||
prize_type = tpl.prize_type or PrizeType.DAYS.value
|
||||
prize_value = tpl.prize_value or "1"
|
||||
|
||||
if prize_type == PrizeType.DAYS.value:
|
||||
prize_display = f"{prize_value} {texts.t('DAYS', 'дн. подписки')}"
|
||||
elif prize_type == PrizeType.BALANCE.value:
|
||||
prize_display = f"{prize_value} коп."
|
||||
elif prize_type == PrizeType.CUSTOM.value:
|
||||
prize_display = prize_value
|
||||
else:
|
||||
prize_display = prize_value
|
||||
|
||||
text = (
|
||||
f"🎲 Стартует игра: <b>{tpl.name}</b>\n"
|
||||
f"Приз: {tpl.prize_days} дн. подписки • Победителей: {tpl.max_winners}\n"
|
||||
f"Попыток/польз: {tpl.attempts_per_user}\n\n"
|
||||
"Участвовать могут только с активной или триальной подпиской."
|
||||
f"🎲 {texts.t('CONTEST_START_ANNOUNCEMENT', 'Стартует игра')}: <b>{tpl.name}</b>\n"
|
||||
f"{texts.t('CONTEST_PRIZE', 'Приз')}: {prize_display} • {texts.t('CONTEST_WINNERS', 'Победителей')}: {tpl.max_winners}\n"
|
||||
f"{texts.t('CONTEST_ATTEMPTS', 'Попыток/польз')}: {tpl.attempts_per_user}\n\n"
|
||||
f"{texts.t('CONTEST_ELIGIBILITY', 'Участвовать могут только с активной или триальной подпиской')}.\n"
|
||||
f"💡 <b>{texts.t('REMINDER', 'Напоминание')}:</b> {texts.t('CONTEST_REMINDER_TEXT', 'Не забудьте участвовать в конкурсах для получения бонусов')}!"
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Contest services module."""
|
||||
|
||||
from app.services.contests.enums import GameType, RoundStatus, PrizeType
|
||||
from app.services.contests.games import get_game_strategy, BaseGameStrategy
|
||||
from app.services.contests.attempt_service import ContestAttemptService
|
||||
|
||||
__all__ = [
|
||||
"GameType",
|
||||
"RoundStatus",
|
||||
"PrizeType",
|
||||
"get_game_strategy",
|
||||
"BaseGameStrategy",
|
||||
"ContestAttemptService",
|
||||
]
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Service for atomic contest attempt operations."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.contest import create_attempt, get_attempt, update_attempt
|
||||
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import ContestAttempt, ContestRound, ContestTemplate
|
||||
from app.services.contests.enums import PrizeType
|
||||
from app.services.contests.games import get_game_strategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AttemptResult:
|
||||
"""Result of processing a contest attempt."""
|
||||
|
||||
success: bool
|
||||
is_winner: bool
|
||||
message: str
|
||||
already_played: bool = False
|
||||
round_finished: bool = False
|
||||
|
||||
|
||||
class ContestAttemptService:
|
||||
"""Service for processing contest attempts with atomic operations."""
|
||||
|
||||
async def process_button_attempt(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
round_obj: ContestRound,
|
||||
user_id: int,
|
||||
pick: str,
|
||||
language: str,
|
||||
) -> AttemptResult:
|
||||
"""
|
||||
Process a button-based game attempt atomically.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
round_obj: Contest round
|
||||
user_id: User ID
|
||||
pick: User's pick (button callback data)
|
||||
language: User's language
|
||||
|
||||
Returns:
|
||||
AttemptResult with outcome details
|
||||
"""
|
||||
tpl = round_obj.template
|
||||
if not tpl:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="Конкурс не найден",
|
||||
)
|
||||
|
||||
# Check if user already played
|
||||
existing_attempt = await get_attempt(db, round_obj.id, user_id)
|
||||
if existing_attempt:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="У вас уже была попытка",
|
||||
already_played=True,
|
||||
)
|
||||
|
||||
# Get game strategy and check answer
|
||||
strategy = get_game_strategy(tpl.slug)
|
||||
if not strategy:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="Тип игры не поддерживается",
|
||||
)
|
||||
|
||||
check_result = strategy.check_answer(pick, round_obj.payload or {}, language)
|
||||
is_winner = check_result.is_correct
|
||||
|
||||
# Atomic winner check with row lock
|
||||
is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
|
||||
|
||||
# Create attempt record
|
||||
await create_attempt(
|
||||
db,
|
||||
round_id=round_obj.id,
|
||||
user_id=user_id,
|
||||
answer=str(pick),
|
||||
is_winner=is_winner,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Contest attempt: user %s, round %s, pick '%s', winner %s",
|
||||
user_id, round_obj.id, pick, is_winner
|
||||
)
|
||||
|
||||
if is_winner:
|
||||
prize_msg = await self._award_prize(db, user_id, tpl, language)
|
||||
return AttemptResult(
|
||||
success=True,
|
||||
is_winner=True,
|
||||
message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
|
||||
)
|
||||
|
||||
return AttemptResult(
|
||||
success=True,
|
||||
is_winner=False,
|
||||
message=check_result.response_text or "Неудача",
|
||||
)
|
||||
|
||||
async def process_text_attempt(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
round_obj: ContestRound,
|
||||
user_id: int,
|
||||
text_answer: str,
|
||||
language: str,
|
||||
) -> AttemptResult:
|
||||
"""
|
||||
Process a text-input game attempt atomically.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
round_obj: Contest round
|
||||
user_id: User ID
|
||||
text_answer: User's text answer
|
||||
language: User's language
|
||||
|
||||
Returns:
|
||||
AttemptResult with outcome details
|
||||
"""
|
||||
tpl = round_obj.template
|
||||
if not tpl:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="Конкурс не найден",
|
||||
)
|
||||
|
||||
# For text games, attempt should already exist (created in render phase)
|
||||
attempt = await get_attempt(db, round_obj.id, user_id)
|
||||
if not attempt:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="Сначала начните игру",
|
||||
)
|
||||
|
||||
# Check if already answered
|
||||
if attempt.answer is not None:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="У вас уже была попытка",
|
||||
already_played=True,
|
||||
)
|
||||
|
||||
# Get game strategy and check answer
|
||||
strategy = get_game_strategy(tpl.slug)
|
||||
if not strategy:
|
||||
return AttemptResult(
|
||||
success=False,
|
||||
is_winner=False,
|
||||
message="Тип игры не поддерживается",
|
||||
)
|
||||
|
||||
check_result = strategy.check_answer(text_answer, round_obj.payload or {}, language)
|
||||
is_winner = check_result.is_correct
|
||||
|
||||
# Atomic winner check with row lock
|
||||
is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
|
||||
|
||||
# Update attempt with answer
|
||||
await update_attempt(db, attempt, answer=text_answer.strip().upper(), is_winner=is_winner)
|
||||
|
||||
logger.info(
|
||||
"Contest text attempt: user %s, round %s, answer '%s', winner %s",
|
||||
user_id, round_obj.id, text_answer, is_winner
|
||||
)
|
||||
|
||||
if is_winner:
|
||||
prize_msg = await self._award_prize(db, user_id, tpl, language)
|
||||
return AttemptResult(
|
||||
success=True,
|
||||
is_winner=True,
|
||||
message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
|
||||
)
|
||||
|
||||
return AttemptResult(
|
||||
success=True,
|
||||
is_winner=False,
|
||||
message=check_result.response_text or "Неверно, попробуй в следующем раунде",
|
||||
)
|
||||
|
||||
async def create_pending_attempt(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
round_id: int,
|
||||
user_id: int,
|
||||
) -> Optional[ContestAttempt]:
|
||||
"""
|
||||
Create a pending attempt for text-input games.
|
||||
This blocks re-entry while user is answering.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
round_id: Round ID
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Created attempt or None if already exists
|
||||
"""
|
||||
existing = await get_attempt(db, round_id, user_id)
|
||||
if existing:
|
||||
return None
|
||||
|
||||
return await create_attempt(
|
||||
db,
|
||||
round_id=round_id,
|
||||
user_id=user_id,
|
||||
answer=None,
|
||||
is_winner=False,
|
||||
)
|
||||
|
||||
async def _atomic_winner_check(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
round_id: int,
|
||||
is_winner: bool,
|
||||
) -> bool:
|
||||
"""
|
||||
Atomically check and increment winner count.
|
||||
Uses SELECT FOR UPDATE to prevent race conditions.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
round_id: Round ID
|
||||
is_winner: Whether user answered correctly
|
||||
|
||||
Returns:
|
||||
True if user is a winner, False if max winners reached
|
||||
"""
|
||||
if not is_winner:
|
||||
return False
|
||||
|
||||
stmt = select(ContestRound).where(ContestRound.id == round_id).with_for_update()
|
||||
result = await db.execute(stmt)
|
||||
round_obj = result.scalar_one()
|
||||
|
||||
if round_obj.winners_count >= round_obj.max_winners:
|
||||
return False
|
||||
|
||||
round_obj.winners_count += 1
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
async def _award_prize(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
template: ContestTemplate,
|
||||
language: str,
|
||||
) -> str:
|
||||
"""
|
||||
Award prize to winner.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Winner user ID
|
||||
template: Contest template with prize info
|
||||
language: User's language
|
||||
|
||||
Returns:
|
||||
Prize notification message
|
||||
"""
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts(language)
|
||||
|
||||
prize_type = template.prize_type or PrizeType.DAYS.value
|
||||
prize_value = template.prize_value or "1"
|
||||
|
||||
if prize_type == PrizeType.DAYS.value:
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
if not subscription:
|
||||
return ""
|
||||
days = int(prize_value) if prize_value.isdigit() else 1
|
||||
await extend_subscription(db, subscription, days)
|
||||
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=days)
|
||||
|
||||
elif prize_type == PrizeType.BALANCE.value:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return ""
|
||||
kopeks = int(prize_value) if prize_value.isdigit() else 0
|
||||
if kopeks > 0:
|
||||
user.balance_kopeks += kopeks
|
||||
await db.commit()
|
||||
return texts.t(
|
||||
"CONTEST_BALANCE_GRANTED",
|
||||
"Бонус {amount} зачислен!"
|
||||
).format(amount=settings.format_price(kopeks))
|
||||
|
||||
elif prize_type == PrizeType.CUSTOM.value:
|
||||
return f"🎁 {prize_value}"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# Singleton instance
|
||||
contest_attempt_service = ContestAttemptService()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Enum classes for contest system."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class GameType(str, Enum):
|
||||
"""Types of daily contest games."""
|
||||
|
||||
QUEST_BUTTONS = "quest_buttons"
|
||||
LOCK_HACK = "lock_hack"
|
||||
LETTER_CIPHER = "letter_cipher"
|
||||
SERVER_LOTTERY = "server_lottery"
|
||||
BLITZ_REACTION = "blitz_reaction"
|
||||
EMOJI_GUESS = "emoji_guess"
|
||||
ANAGRAM = "anagram"
|
||||
|
||||
@classmethod
|
||||
def is_text_input(cls, game_type: "GameType") -> bool:
|
||||
"""Check if game requires text input from user."""
|
||||
return game_type in {cls.LETTER_CIPHER, cls.EMOJI_GUESS, cls.ANAGRAM}
|
||||
|
||||
@classmethod
|
||||
def is_button_pick(cls, game_type: "GameType") -> bool:
|
||||
"""Check if game uses button selection."""
|
||||
return game_type in {
|
||||
cls.QUEST_BUTTONS,
|
||||
cls.LOCK_HACK,
|
||||
cls.SERVER_LOTTERY,
|
||||
cls.BLITZ_REACTION,
|
||||
}
|
||||
|
||||
|
||||
class RoundStatus(str, Enum):
|
||||
"""Contest round status."""
|
||||
|
||||
ACTIVE = "active"
|
||||
FINISHED = "finished"
|
||||
|
||||
|
||||
class PrizeType(str, Enum):
|
||||
"""Types of prizes for contests."""
|
||||
|
||||
DAYS = "days"
|
||||
BALANCE = "balance"
|
||||
CUSTOM = "custom"
|
||||
@@ -0,0 +1,473 @@
|
||||
"""Game strategies for different contest types."""
|
||||
|
||||
import random
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from aiogram import types
|
||||
|
||||
from app.services.contests.enums import GameType
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameRenderResult:
|
||||
"""Result of rendering a game."""
|
||||
|
||||
text: str
|
||||
keyboard: types.InlineKeyboardMarkup
|
||||
requires_text_input: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnswerCheckResult:
|
||||
"""Result of checking user's answer."""
|
||||
|
||||
is_correct: bool
|
||||
response_text: str
|
||||
|
||||
|
||||
class BaseGameStrategy(ABC):
|
||||
"""Base class for game strategies."""
|
||||
|
||||
game_type: GameType
|
||||
|
||||
@abstractmethod
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Build round-specific payload from template config."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
"""Render game UI for user."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
"""Check if user's answer is correct."""
|
||||
pass
|
||||
|
||||
def _get_back_button(self, language: str, callback: str) -> types.InlineKeyboardButton:
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts(language)
|
||||
return types.InlineKeyboardButton(text=texts.BACK, callback_data=callback)
|
||||
|
||||
def _get_texts(self, language: str):
|
||||
from app.localization.texts import get_texts
|
||||
return get_texts(language)
|
||||
|
||||
|
||||
class QuestButtonsStrategy(BaseGameStrategy):
|
||||
"""3x3 grid game - find the secret button."""
|
||||
|
||||
game_type = GameType.QUEST_BUTTONS
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
rows = template_payload.get("rows", 3)
|
||||
cols = template_payload.get("cols", 3)
|
||||
total = rows * cols
|
||||
secret_idx = random.randint(0, total - 1)
|
||||
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
rows = payload.get("rows", 3)
|
||||
cols = payload.get("cols", 3)
|
||||
|
||||
keyboard_rows = []
|
||||
for r in range(rows):
|
||||
row_buttons = []
|
||||
for c in range(cols):
|
||||
idx = r * cols + c
|
||||
row_buttons.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="🎛",
|
||||
callback_data=f"contest_pick_{round_id}_quest_{idx}",
|
||||
)
|
||||
)
|
||||
keyboard_rows.append(row_buttons)
|
||||
keyboard_rows.append([self._get_back_button(language, back_callback)])
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
|
||||
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
secret_idx = payload.get("secret_idx")
|
||||
try:
|
||||
if user_answer.startswith("quest_"):
|
||||
idx = int(user_answer.split("_")[1])
|
||||
is_correct = secret_idx is not None and idx == secret_idx
|
||||
else:
|
||||
is_correct = False
|
||||
except (ValueError, IndexError):
|
||||
is_correct = False
|
||||
|
||||
responses = ["Пусто", "Ложный сервер", "Найди другой узел"]
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else random.choice(responses),
|
||||
)
|
||||
|
||||
|
||||
class LockHackStrategy(BaseGameStrategy):
|
||||
"""20 locks game - find the hacked one."""
|
||||
|
||||
game_type = GameType.LOCK_HACK
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
total = template_payload.get("buttons", 20)
|
||||
secret_idx = random.randint(0, max(0, total - 1))
|
||||
return {"total": total, "secret_idx": secret_idx}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
total = payload.get("total", 20)
|
||||
|
||||
keyboard_rows = []
|
||||
row = []
|
||||
for i in range(total):
|
||||
row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text="🔒",
|
||||
callback_data=f"contest_pick_{round_id}_locks_{i}",
|
||||
)
|
||||
)
|
||||
if len(row) == 5:
|
||||
keyboard_rows.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard_rows.append(row)
|
||||
keyboard_rows.append([self._get_back_button(language, back_callback)])
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
|
||||
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
secret_idx = payload.get("secret_idx")
|
||||
try:
|
||||
if user_answer.startswith("locks_"):
|
||||
idx = int(user_answer.split("_")[1])
|
||||
is_correct = secret_idx is not None and idx == secret_idx
|
||||
else:
|
||||
is_correct = False
|
||||
except (ValueError, IndexError):
|
||||
is_correct = False
|
||||
|
||||
responses = ["Заблокировано", "Попробуй ещё", "Нет доступа"]
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else random.choice(responses),
|
||||
)
|
||||
|
||||
|
||||
class ServerLotteryStrategy(BaseGameStrategy):
|
||||
"""Flag lottery game - pick the correct server flag."""
|
||||
|
||||
game_type = GameType.SERVER_LOTTERY
|
||||
|
||||
DEFAULT_FLAGS = ["🇸🇪", "🇸🇬", "🇺🇸", "🇷🇺", "🇩🇪", "🇯🇵", "🇧🇷", "🇦🇺", "🇨🇦", "🇫🇷"]
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
flags = template_payload.get("flags") or self.DEFAULT_FLAGS
|
||||
secret_idx = random.randint(0, len(flags) - 1)
|
||||
return {"flags": flags, "secret_idx": secret_idx}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
flags = payload.get("flags") or []
|
||||
shuffled_flags = flags.copy()
|
||||
random.shuffle(shuffled_flags)
|
||||
|
||||
keyboard_rows = []
|
||||
row = []
|
||||
for flag in shuffled_flags:
|
||||
row.append(
|
||||
types.InlineKeyboardButton(
|
||||
text=flag,
|
||||
callback_data=f"contest_pick_{round_id}_{flag}",
|
||||
)
|
||||
)
|
||||
if len(row) == 5:
|
||||
keyboard_rows.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard_rows.append(row)
|
||||
keyboard_rows.append([self._get_back_button(language, back_callback)])
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
|
||||
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
secret_idx = payload.get("secret_idx")
|
||||
flags = payload.get("flags") or []
|
||||
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
|
||||
is_correct = user_answer == correct_flag
|
||||
|
||||
responses = ["Сервер перегружен", "Нет ответа", "Попробуй завтра"]
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else random.choice(responses),
|
||||
)
|
||||
|
||||
|
||||
class BlitzReactionStrategy(BaseGameStrategy):
|
||||
"""Blitz reaction game - press button quickly."""
|
||||
|
||||
game_type = GameType.BLITZ_REACTION
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {"timeout_seconds": template_payload.get("timeout_seconds", 10)}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"),
|
||||
callback_data=f"contest_pick_{round_id}_blitz",
|
||||
)
|
||||
],
|
||||
[self._get_back_button(language, back_callback)],
|
||||
]
|
||||
)
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
|
||||
keyboard=keyboard,
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
is_correct = user_answer == "blitz"
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else "Время вышло",
|
||||
)
|
||||
|
||||
|
||||
class LetterCipherStrategy(BaseGameStrategy):
|
||||
"""Letter cipher game - decode word from letter codes."""
|
||||
|
||||
game_type = GameType.LETTER_CIPHER
|
||||
|
||||
DEFAULT_WORDS = ["VPN", "SERVER", "PROXY", "XRAY"]
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
words = template_payload.get("words") or self.DEFAULT_WORDS
|
||||
word = random.choice(words)
|
||||
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
|
||||
return {"question": "-".join(codes), "answer": word.upper()}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
question = payload.get("question", "")
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
|
||||
keyboard=get_back_keyboard(language),
|
||||
requires_text_input=True,
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
correct = (payload.get("answer") or "").upper()
|
||||
is_correct = correct and user_answer.strip().upper() == correct
|
||||
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
|
||||
)
|
||||
|
||||
|
||||
class EmojiGuessStrategy(BaseGameStrategy):
|
||||
"""Emoji guess game - guess service by emoji."""
|
||||
|
||||
game_type = GameType.EMOJI_GUESS
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pairs = template_payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
|
||||
pair = random.choice(pairs)
|
||||
return pair
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
question = payload.get("question", "🤔")
|
||||
emoji_list = question.split()
|
||||
random.shuffle(emoji_list)
|
||||
shuffled_question = " ".join(emoji_list)
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(
|
||||
q=shuffled_question
|
||||
),
|
||||
keyboard=get_back_keyboard(language),
|
||||
requires_text_input=True,
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
correct = (payload.get("answer") or "").upper()
|
||||
is_correct = correct and user_answer.strip().upper() == correct
|
||||
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
|
||||
)
|
||||
|
||||
|
||||
class AnagramStrategy(BaseGameStrategy):
|
||||
"""Anagram game - unscramble letters to form a word."""
|
||||
|
||||
game_type = GameType.ANAGRAM
|
||||
|
||||
DEFAULT_WORDS = ["SERVER", "XRAY", "VPN"]
|
||||
|
||||
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
words = template_payload.get("words") or self.DEFAULT_WORDS
|
||||
word = random.choice(words).upper()
|
||||
shuffled = "".join(random.sample(word, len(word)))
|
||||
return {"letters": shuffled, "answer": word}
|
||||
|
||||
def render(
|
||||
self,
|
||||
round_id: int,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
back_callback: str = "contests_menu",
|
||||
) -> GameRenderResult:
|
||||
texts = self._get_texts(language)
|
||||
letters = payload.get("letters", "")
|
||||
from app.keyboards.inline import get_back_keyboard
|
||||
|
||||
return GameRenderResult(
|
||||
text=texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(
|
||||
letters=letters
|
||||
),
|
||||
keyboard=get_back_keyboard(language),
|
||||
requires_text_input=True,
|
||||
)
|
||||
|
||||
def check_answer(
|
||||
self,
|
||||
user_answer: str,
|
||||
payload: Dict[str, Any],
|
||||
language: str,
|
||||
) -> AnswerCheckResult:
|
||||
correct = (payload.get("answer") or "").upper()
|
||||
is_correct = correct and user_answer.strip().upper() == correct
|
||||
|
||||
return AnswerCheckResult(
|
||||
is_correct=is_correct,
|
||||
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
|
||||
)
|
||||
|
||||
|
||||
# Registry of game strategies
|
||||
_GAME_STRATEGIES: Dict[GameType, BaseGameStrategy] = {
|
||||
GameType.QUEST_BUTTONS: QuestButtonsStrategy(),
|
||||
GameType.LOCK_HACK: LockHackStrategy(),
|
||||
GameType.SERVER_LOTTERY: ServerLotteryStrategy(),
|
||||
GameType.BLITZ_REACTION: BlitzReactionStrategy(),
|
||||
GameType.LETTER_CIPHER: LetterCipherStrategy(),
|
||||
GameType.EMOJI_GUESS: EmojiGuessStrategy(),
|
||||
GameType.ANAGRAM: AnagramStrategy(),
|
||||
}
|
||||
|
||||
|
||||
def get_game_strategy(game_type: GameType | str) -> Optional[BaseGameStrategy]:
|
||||
"""Get game strategy by type."""
|
||||
if isinstance(game_type, str):
|
||||
try:
|
||||
game_type = GameType(game_type)
|
||||
except ValueError:
|
||||
return None
|
||||
return _GAME_STRATEGIES.get(game_type)
|
||||
|
||||
|
||||
def get_all_game_types() -> List[GameType]:
|
||||
"""Get list of all supported game types."""
|
||||
return list(_GAME_STRATEGIES.keys())
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Сервис ротации логов с отправкой в Telegram.
|
||||
|
||||
Функционал:
|
||||
- Ежедневная ротация в настроенное время (по умолчанию 00:00)
|
||||
- Разделение по уровням: info.log, warning.log, error.log
|
||||
- Отдельный лог платежей: payments.log
|
||||
- Архивирование всех логов за день в один tar.gz
|
||||
- Отправка архива в Telegram-канал
|
||||
- Очистка архивов старше N дней
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import tarfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.timezone import get_local_timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogRotationStatus:
|
||||
"""Статус сервиса ротации логов."""
|
||||
|
||||
enabled: bool
|
||||
running: bool
|
||||
rotation_time: str
|
||||
keep_days: int
|
||||
send_to_telegram: bool
|
||||
next_rotation: Optional[str]
|
||||
log_dir: str
|
||||
archive_count: int
|
||||
|
||||
|
||||
class LogRotationService:
|
||||
"""Сервис ежедневной ротации и архивации логов."""
|
||||
|
||||
def __init__(self, bot: Optional[Bot] = None):
|
||||
self.bot = bot
|
||||
self._rotation_task: Optional[asyncio.Task] = None
|
||||
self._running = False
|
||||
self._handlers: List[logging.Handler] = []
|
||||
|
||||
# Пути
|
||||
self.log_dir = Path(settings.LOG_DIR).resolve()
|
||||
self.current_dir = self.log_dir / "current"
|
||||
self.archive_dir = self.log_dir / "archive"
|
||||
|
||||
@property
|
||||
def log_files(self) -> Dict[str, Path]:
|
||||
"""Пути к текущим лог-файлам."""
|
||||
return {
|
||||
"bot": self.current_dir / "bot.log",
|
||||
"info": self.current_dir / settings.LOG_INFO_FILE,
|
||||
"warning": self.current_dir / settings.LOG_WARNING_FILE,
|
||||
"error": self.current_dir / settings.LOG_ERROR_FILE,
|
||||
"payments": self.current_dir / settings.LOG_PAYMENTS_FILE,
|
||||
}
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
"""Установить экземпляр бота для отправки логов."""
|
||||
self.bot = bot
|
||||
|
||||
def register_handlers(self, handlers: List[logging.Handler]) -> None:
|
||||
"""Зарегистрировать хэндлеры для управления при ротации."""
|
||||
self._handlers = handlers
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Создать необходимые директории."""
|
||||
self.current_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Запустить сервис ротации."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._rotation_task = asyncio.create_task(self._rotation_loop())
|
||||
logger.info("Сервис ротации логов запущен")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Остановить сервис ротации."""
|
||||
self._running = False
|
||||
if self._rotation_task and not self._rotation_task.done():
|
||||
self._rotation_task.cancel()
|
||||
try:
|
||||
await self._rotation_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Сервис ротации логов остановлен")
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Проверить, запущен ли сервис."""
|
||||
return self._running
|
||||
|
||||
async def _rotation_loop(self) -> None:
|
||||
"""Основной цикл ожидания времени ротации."""
|
||||
while self._running:
|
||||
next_rotation = self._calculate_next_rotation_time()
|
||||
now = datetime.now(get_local_timezone())
|
||||
wait_seconds = (next_rotation - now).total_seconds()
|
||||
|
||||
if wait_seconds > 0:
|
||||
logger.info(
|
||||
"Следующая ротация логов: %s (через %.1f часов)",
|
||||
next_rotation.strftime("%Y-%m-%d %H:%M"),
|
||||
wait_seconds / 3600,
|
||||
)
|
||||
try:
|
||||
await asyncio.sleep(wait_seconds)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
if self._running:
|
||||
await self.rotate_logs()
|
||||
|
||||
def _calculate_next_rotation_time(self) -> datetime:
|
||||
"""Вычислить время следующей ротации."""
|
||||
now = datetime.now(get_local_timezone())
|
||||
|
||||
# Парсим время ротации
|
||||
time_str = settings.LOG_ROTATION_TIME
|
||||
try:
|
||||
hours, minutes = map(int, time_str.split(":"))
|
||||
except ValueError:
|
||||
hours, minutes = 0, 0
|
||||
logger.warning(
|
||||
"Некорректное LOG_ROTATION_TIME='%s', используем 00:00", time_str
|
||||
)
|
||||
|
||||
next_rotation = now.replace(hour=hours, minute=minutes, second=0, microsecond=0)
|
||||
|
||||
# Если время уже прошло сегодня, ротация завтра
|
||||
if next_rotation <= now:
|
||||
next_rotation += timedelta(days=1)
|
||||
|
||||
return next_rotation
|
||||
|
||||
async def rotate_logs(self) -> Tuple[bool, str]:
|
||||
"""Выполнить ротацию логов.
|
||||
|
||||
Создаёт один архив logs_YYYY-MM-DD.tar.gz со всеми лог-файлами за день.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: (успех, сообщение)
|
||||
"""
|
||||
try:
|
||||
logger.info("Начинаем ротацию логов...")
|
||||
|
||||
# Дата для архива (вчера, т.к. логи были за предыдущие сутки)
|
||||
yesterday = (
|
||||
datetime.now(get_local_timezone()) - timedelta(days=1)
|
||||
).strftime("%Y-%m-%d")
|
||||
|
||||
# Сбрасываем буферы хэндлеров перед архивацией
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Собираем файлы для архивации
|
||||
files_to_archive: List[Tuple[Path, str]] = []
|
||||
for name, log_path in self.log_files.items():
|
||||
if log_path.exists() and log_path.stat().st_size > 0:
|
||||
files_to_archive.append((log_path, f"{name}.log"))
|
||||
|
||||
if not files_to_archive:
|
||||
message = "Нет логов для архивации"
|
||||
logger.info(message)
|
||||
return True, message
|
||||
|
||||
# Создаём один архив со всеми логами
|
||||
archive_path = await self._create_archive(files_to_archive, yesterday)
|
||||
|
||||
if archive_path:
|
||||
# Очищаем текущие лог-файлы
|
||||
for log_path, _ in files_to_archive:
|
||||
log_path.write_text("")
|
||||
|
||||
# Очистка старых архивов
|
||||
await self._cleanup_old_archives()
|
||||
|
||||
# Отправка в Telegram
|
||||
if settings.LOG_ROTATION_SEND_TO_TELEGRAM and self.bot:
|
||||
await self._send_logs_to_telegram(archive_path, yesterday)
|
||||
|
||||
message = f"Ротация логов завершена. Архив: {archive_path.name}"
|
||||
logger.info(message)
|
||||
return True, message
|
||||
else:
|
||||
message = "Ошибка создания архива логов"
|
||||
logger.error(message)
|
||||
return False, message
|
||||
|
||||
except Exception as error:
|
||||
message = f"Ошибка ротации логов: {error}"
|
||||
logger.error(message, exc_info=True)
|
||||
return False, message
|
||||
|
||||
async def _create_archive(
|
||||
self,
|
||||
files: List[Tuple[Path, str]],
|
||||
date_str: str,
|
||||
) -> Optional[Path]:
|
||||
"""Создать архив со всеми логами за день.
|
||||
|
||||
Args:
|
||||
files: список (путь к файлу, имя в архиве)
|
||||
date_str: дата в формате YYYY-MM-DD
|
||||
|
||||
Returns:
|
||||
Путь к созданному архиву или None при ошибке
|
||||
"""
|
||||
try:
|
||||
if settings.LOG_ROTATION_COMPRESS:
|
||||
archive_name = f"logs_{date_str}.tar.gz"
|
||||
mode = "w:gz"
|
||||
else:
|
||||
archive_name = f"logs_{date_str}.tar"
|
||||
mode = "w"
|
||||
|
||||
archive_path = self.archive_dir / archive_name
|
||||
|
||||
def _create_tar():
|
||||
with tarfile.open(archive_path, mode) as tar:
|
||||
for file_path, arcname in files:
|
||||
tar.add(file_path, arcname=arcname)
|
||||
|
||||
await asyncio.to_thread(_create_tar)
|
||||
logger.debug("Создан архив: %s", archive_path)
|
||||
return archive_path
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка создания архива: %s", error)
|
||||
return None
|
||||
|
||||
async def _cleanup_old_archives(self) -> None:
|
||||
"""Удалить архивы старше LOG_ROTATION_KEEP_DAYS."""
|
||||
keep_days = settings.LOG_ROTATION_KEEP_DAYS
|
||||
cutoff_date = datetime.now(get_local_timezone()) - timedelta(days=keep_days)
|
||||
|
||||
if not self.archive_dir.exists():
|
||||
return
|
||||
|
||||
# Ищем файлы вида logs_YYYY-MM-DD.tar.gz или logs_YYYY-MM-DD.tar
|
||||
for archive_file in self.archive_dir.iterdir():
|
||||
if not archive_file.is_file():
|
||||
continue
|
||||
|
||||
# Извлекаем дату из имени файла logs_YYYY-MM-DD.tar.gz
|
||||
name = archive_file.name
|
||||
if not name.startswith("logs_"):
|
||||
continue
|
||||
|
||||
try:
|
||||
# logs_2025-01-26.tar.gz -> 2025-01-26
|
||||
date_part = name.replace("logs_", "").replace(".tar.gz", "").replace(".tar", "")
|
||||
file_date = datetime.strptime(date_part, "%Y-%m-%d")
|
||||
file_date = file_date.replace(tzinfo=get_local_timezone())
|
||||
|
||||
if file_date < cutoff_date:
|
||||
archive_file.unlink()
|
||||
logger.info("Удален старый архив логов: %s", archive_file.name)
|
||||
except ValueError:
|
||||
# Пропускаем файлы с некорректным форматом имени
|
||||
pass
|
||||
|
||||
async def _send_logs_to_telegram(
|
||||
self,
|
||||
archive_path: Path,
|
||||
date_str: str,
|
||||
) -> None:
|
||||
"""Отправить архив логов в Telegram."""
|
||||
chat_id = settings.get_log_rotation_chat_id()
|
||||
if not chat_id:
|
||||
logger.warning("LOG_ROTATION_CHAT_ID не задан, пропускаем отправку")
|
||||
return
|
||||
|
||||
topic_id = settings.get_log_rotation_topic_id()
|
||||
|
||||
try:
|
||||
file_size_kb = archive_path.stat().st_size / 1024
|
||||
caption = (
|
||||
f"<b>Логи бота</b>\n"
|
||||
f"Дата: {date_str}\n"
|
||||
f"Файл: <code>{archive_path.name}</code>\n"
|
||||
f"Размер: {file_size_kb:.1f} KB"
|
||||
)
|
||||
|
||||
send_kwargs = {
|
||||
"chat_id": chat_id,
|
||||
"document": FSInputFile(archive_path),
|
||||
"caption": caption,
|
||||
"parse_mode": "HTML",
|
||||
}
|
||||
|
||||
if topic_id:
|
||||
send_kwargs["message_thread_id"] = topic_id
|
||||
|
||||
await self.bot.send_document(**send_kwargs)
|
||||
logger.info("Архив логов отправлен: %s", archive_path.name)
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка отправки архива %s: %s", archive_path.name, error)
|
||||
|
||||
# === Ручные операции ===
|
||||
|
||||
async def force_rotate(self) -> Tuple[bool, str]:
|
||||
"""Принудительная ротация (для админ-команды)."""
|
||||
return await self.rotate_logs()
|
||||
|
||||
def get_status(self) -> LogRotationStatus:
|
||||
"""Получить статус сервиса."""
|
||||
archive_count = 0
|
||||
if self.archive_dir.exists():
|
||||
# Считаем файлы logs_*.tar.gz или logs_*.tar
|
||||
archive_count = len(
|
||||
[f for f in self.archive_dir.iterdir()
|
||||
if f.is_file() and f.name.startswith("logs_")]
|
||||
)
|
||||
|
||||
next_rotation = None
|
||||
if self._running:
|
||||
next_rotation = self._calculate_next_rotation_time().isoformat()
|
||||
|
||||
return LogRotationStatus(
|
||||
enabled=settings.is_log_rotation_enabled(),
|
||||
running=self._running,
|
||||
rotation_time=settings.LOG_ROTATION_TIME,
|
||||
keep_days=settings.LOG_ROTATION_KEEP_DAYS,
|
||||
send_to_telegram=settings.LOG_ROTATION_SEND_TO_TELEGRAM,
|
||||
next_rotation=next_rotation,
|
||||
log_dir=str(self.log_dir),
|
||||
archive_count=archive_count,
|
||||
)
|
||||
|
||||
|
||||
# Глобальный экземпляр сервиса
|
||||
log_rotation_service = LogRotationService()
|
||||
@@ -293,6 +293,8 @@ class MaintenanceService:
|
||||
secret_key = (auth_params.get("secret_key") or "").strip() or None
|
||||
username = (auth_params.get("username") or "").strip() or None
|
||||
password = (auth_params.get("password") or "").strip() or None
|
||||
caddy_token = (auth_params.get("caddy_token") or "").strip() or None
|
||||
auth_type = (auth_params.get("auth_type") or "api_key").strip()
|
||||
|
||||
if not base_url:
|
||||
logger.error("REMNAWAVE_API_URL не настроен, пропускаем проверку API")
|
||||
@@ -311,7 +313,9 @@ class MaintenanceService:
|
||||
api_key=api_key,
|
||||
secret_key=secret_key,
|
||||
username=username,
|
||||
password=password
|
||||
password=password,
|
||||
caddy_token=caddy_token,
|
||||
auth_type=auth_type,
|
||||
)
|
||||
|
||||
attempts = settings.get_maintenance_retry_attempts()
|
||||
|
||||
@@ -128,7 +128,7 @@ DEFAULT_MENU_CONFIG: Dict[str, Any] = {
|
||||
"action": "buy_traffic",
|
||||
"enabled": True,
|
||||
"visibility": "subscribers",
|
||||
"conditions": {"has_traffic_limit": True},
|
||||
"conditions": {"has_traffic_limit": True, "traffic_topup_enabled": True},
|
||||
"dynamic_text": False,
|
||||
},
|
||||
"balance": {
|
||||
|
||||
@@ -715,6 +715,11 @@ class MenuLayoutService:
|
||||
if is_trial or traffic_limit <= 0:
|
||||
return False
|
||||
|
||||
# traffic_topup_enabled - функция докупки трафика включена
|
||||
if conditions.get("traffic_topup_enabled") is True:
|
||||
if not settings.is_traffic_topup_enabled():
|
||||
return False
|
||||
|
||||
# is_admin
|
||||
if conditions.get("is_admin") is True:
|
||||
if not context.is_admin:
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Сервис для управления модемом в подписке.
|
||||
|
||||
Модем - это дополнительное устройство, которое можно подключить к подписке
|
||||
за отдельную плату. При подключении увеличивается лимит устройств.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User, TransactionType
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModemError(Enum):
|
||||
"""Типы ошибок при работе с модемом."""
|
||||
NO_SUBSCRIPTION = "no_subscription"
|
||||
TRIAL_SUBSCRIPTION = "trial_subscription"
|
||||
MODEM_DISABLED = "modem_disabled"
|
||||
ALREADY_ENABLED = "already_enabled"
|
||||
NOT_ENABLED = "not_enabled"
|
||||
INSUFFICIENT_FUNDS = "insufficient_funds"
|
||||
CHARGE_ERROR = "charge_error"
|
||||
UPDATE_ERROR = "update_error"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemAvailabilityResult:
|
||||
"""Результат проверки доступности модема."""
|
||||
available: bool
|
||||
error: Optional[ModemError] = None
|
||||
modem_enabled: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemPriceResult:
|
||||
"""Результат расчёта цены модема."""
|
||||
base_price: int
|
||||
final_price: int
|
||||
discount_percent: int
|
||||
discount_amount: int
|
||||
charged_months: int
|
||||
remaining_days: int
|
||||
end_date: datetime
|
||||
|
||||
@property
|
||||
def has_discount(self) -> bool:
|
||||
return self.discount_percent > 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemEnableResult:
|
||||
"""Результат подключения модема."""
|
||||
success: bool
|
||||
error: Optional[ModemError] = None
|
||||
charged_amount: int = 0
|
||||
new_device_limit: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModemDisableResult:
|
||||
"""Результат отключения модема."""
|
||||
success: bool
|
||||
error: Optional[ModemError] = None
|
||||
new_device_limit: int = 0
|
||||
|
||||
|
||||
# Константы для предупреждений о сроке действия
|
||||
MODEM_WARNING_DAYS_CRITICAL = 7
|
||||
MODEM_WARNING_DAYS_INFO = 30
|
||||
|
||||
|
||||
class ModemService:
|
||||
"""
|
||||
Сервис для управления модемом в подписке.
|
||||
|
||||
Инкапсулирует всю бизнес-логику:
|
||||
- Проверки доступности
|
||||
- Расчёт цен и скидок
|
||||
- Подключение/отключение модема
|
||||
- Синхронизация с RemnaWave
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._subscription_service = SubscriptionService()
|
||||
|
||||
@staticmethod
|
||||
def is_modem_feature_enabled() -> bool:
|
||||
"""Проверяет, включена ли функция модема в настройках."""
|
||||
return settings.is_modem_enabled()
|
||||
|
||||
@staticmethod
|
||||
def get_modem_enabled(subscription: Optional[Subscription]) -> bool:
|
||||
"""Безопасно получает статус модема из подписки."""
|
||||
if subscription is None:
|
||||
return False
|
||||
return getattr(subscription, 'modem_enabled', False) or False
|
||||
|
||||
def check_availability(
|
||||
self,
|
||||
user: User,
|
||||
for_enable: bool = False,
|
||||
for_disable: bool = False
|
||||
) -> ModemAvailabilityResult:
|
||||
"""
|
||||
Проверяет доступность модема для пользователя.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
for_enable: Проверка для подключения (модем должен быть отключен)
|
||||
for_disable: Проверка для отключения (модем должен быть включен)
|
||||
|
||||
Returns:
|
||||
ModemAvailabilityResult с результатом проверки
|
||||
"""
|
||||
subscription = user.subscription
|
||||
modem_enabled = self.get_modem_enabled(subscription)
|
||||
|
||||
if not subscription:
|
||||
return ModemAvailabilityResult(
|
||||
available=False,
|
||||
error=ModemError.NO_SUBSCRIPTION,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if subscription.is_trial:
|
||||
return ModemAvailabilityResult(
|
||||
available=False,
|
||||
error=ModemError.TRIAL_SUBSCRIPTION,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if not self.is_modem_feature_enabled():
|
||||
return ModemAvailabilityResult(
|
||||
available=False,
|
||||
error=ModemError.MODEM_DISABLED,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if for_enable and modem_enabled:
|
||||
return ModemAvailabilityResult(
|
||||
available=False,
|
||||
error=ModemError.ALREADY_ENABLED,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
if for_disable and not modem_enabled:
|
||||
return ModemAvailabilityResult(
|
||||
available=False,
|
||||
error=ModemError.NOT_ENABLED,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
return ModemAvailabilityResult(
|
||||
available=True,
|
||||
modem_enabled=modem_enabled
|
||||
)
|
||||
|
||||
def calculate_price(self, subscription: Subscription) -> ModemPriceResult:
|
||||
"""
|
||||
Рассчитывает стоимость подключения модема.
|
||||
|
||||
Использует пропорциональную цену на основе оставшегося времени подписки
|
||||
и применяет скидки в зависимости от периода.
|
||||
|
||||
Args:
|
||||
subscription: Подписка пользователя
|
||||
|
||||
Returns:
|
||||
ModemPriceResult с детализацией цены
|
||||
"""
|
||||
modem_price_per_month = settings.get_modem_price_per_month()
|
||||
|
||||
base_price, charged_months = calculate_prorated_price(
|
||||
modem_price_per_month,
|
||||
subscription.end_date,
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
remaining_days = max(0, (subscription.end_date - now).days)
|
||||
|
||||
discount_percent = settings.get_modem_period_discount(charged_months)
|
||||
if discount_percent > 0:
|
||||
discount_amount = base_price * discount_percent // 100
|
||||
final_price = base_price - discount_amount
|
||||
else:
|
||||
discount_amount = 0
|
||||
final_price = base_price
|
||||
|
||||
return ModemPriceResult(
|
||||
base_price=base_price,
|
||||
final_price=final_price,
|
||||
discount_percent=discount_percent,
|
||||
discount_amount=discount_amount,
|
||||
charged_months=charged_months,
|
||||
remaining_days=remaining_days,
|
||||
end_date=subscription.end_date
|
||||
)
|
||||
|
||||
def check_balance(self, user: User, price: int) -> Tuple[bool, int]:
|
||||
"""
|
||||
Проверяет достаточность баланса.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
price: Требуемая сумма
|
||||
|
||||
Returns:
|
||||
Tuple[достаточно ли средств, недостающая сумма]
|
||||
"""
|
||||
if price <= 0:
|
||||
return True, 0
|
||||
|
||||
if user.balance_kopeks >= price:
|
||||
return True, 0
|
||||
|
||||
missing = price - user.balance_kopeks
|
||||
return False, missing
|
||||
|
||||
async def enable_modem(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription
|
||||
) -> ModemEnableResult:
|
||||
"""
|
||||
Подключает модем к подписке.
|
||||
|
||||
Выполняет:
|
||||
1. Расчёт цены
|
||||
2. Проверку баланса
|
||||
3. Списание средств
|
||||
4. Создание транзакции
|
||||
5. Обновление подписки
|
||||
6. Синхронизацию с RemnaWave
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user: Пользователь
|
||||
subscription: Подписка
|
||||
|
||||
Returns:
|
||||
ModemEnableResult с результатом операции
|
||||
"""
|
||||
price_info = self.calculate_price(subscription)
|
||||
price = price_info.final_price
|
||||
|
||||
has_funds, _ = self.check_balance(user, price)
|
||||
if not has_funds:
|
||||
return ModemEnableResult(
|
||||
success=False,
|
||||
error=ModemError.INSUFFICIENT_FUNDS
|
||||
)
|
||||
|
||||
try:
|
||||
if price > 0:
|
||||
success = await subtract_user_balance(
|
||||
db, user, price,
|
||||
"Подключение модема"
|
||||
)
|
||||
|
||||
if not success:
|
||||
return ModemEnableResult(
|
||||
success=False,
|
||||
error=ModemError.CHARGE_ERROR
|
||||
)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=price,
|
||||
description=f"Подключение модема на {price_info.charged_months} мес"
|
||||
)
|
||||
|
||||
subscription.modem_enabled = True
|
||||
subscription.device_limit = (subscription.device_limit or 1) + 1
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
f"Пользователь {user.telegram_id} подключил модем, списано: {price / 100}₽"
|
||||
)
|
||||
|
||||
return ModemEnableResult(
|
||||
success=True,
|
||||
charged_amount=price,
|
||||
new_device_limit=subscription.device_limit
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка подключения модема для пользователя {user.telegram_id}: {e}")
|
||||
await db.rollback()
|
||||
return ModemEnableResult(
|
||||
success=False,
|
||||
error=ModemError.UPDATE_ERROR
|
||||
)
|
||||
|
||||
async def disable_modem(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
subscription: Subscription
|
||||
) -> ModemDisableResult:
|
||||
"""
|
||||
Отключает модем от подписки.
|
||||
|
||||
Возврат средств не производится.
|
||||
|
||||
Args:
|
||||
db: Сессия базы данных
|
||||
user: Пользователь
|
||||
subscription: Подписка
|
||||
|
||||
Returns:
|
||||
ModemDisableResult с результатом операции
|
||||
"""
|
||||
try:
|
||||
subscription.modem_enabled = False
|
||||
if subscription.device_limit and subscription.device_limit > 1:
|
||||
subscription.device_limit = subscription.device_limit - 1
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
|
||||
await self._subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"Пользователь {user.telegram_id} отключил модем")
|
||||
|
||||
return ModemDisableResult(
|
||||
success=True,
|
||||
new_device_limit=subscription.device_limit
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отключения модема для пользователя {user.telegram_id}: {e}")
|
||||
await db.rollback()
|
||||
return ModemDisableResult(
|
||||
success=False,
|
||||
error=ModemError.UPDATE_ERROR
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_period_warning_level(remaining_days: int) -> Optional[str]:
|
||||
"""
|
||||
Определяет уровень предупреждения о сроке действия.
|
||||
|
||||
Args:
|
||||
remaining_days: Оставшиеся дни подписки
|
||||
|
||||
Returns:
|
||||
"critical" если <= 7 дней
|
||||
"info" если <= 30 дней
|
||||
None если больше 30 дней
|
||||
"""
|
||||
if remaining_days <= MODEM_WARNING_DAYS_CRITICAL:
|
||||
return "critical"
|
||||
if remaining_days <= MODEM_WARNING_DAYS_INFO:
|
||||
return "info"
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance для использования в хендлерах
|
||||
_modem_service: Optional[ModemService] = None
|
||||
|
||||
|
||||
def get_modem_service() -> ModemService:
|
||||
"""Возвращает singleton экземпляр ModemService."""
|
||||
global _modem_service
|
||||
if _modem_service is None:
|
||||
_modem_service = ModemService()
|
||||
return _modem_service
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Фоновый сервис для обработки очереди чеков NaloGO.
|
||||
|
||||
При временной недоступности сервиса nalog.ru (503), чеки сохраняются в Redis
|
||||
и отправляются позже этим сервисом.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from dateutil.parser import isoparse
|
||||
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.nalogo_service import NaloGoService
|
||||
from app.utils.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NalogoQueueService:
|
||||
"""Сервис фоновой обработки очереди чеков NaloGO."""
|
||||
|
||||
def __init__(self, nalogo_service: Optional[NaloGoService] = None):
|
||||
self._nalogo_service = nalogo_service
|
||||
self._bot: Optional[Bot] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._running = False
|
||||
self._last_notification_time: Optional[datetime] = None
|
||||
self._notification_cooldown = timedelta(hours=1) # Не чаще раза в час
|
||||
self._had_pending_receipts = False # Флаг для отслеживания успешной разгрузки
|
||||
|
||||
def set_nalogo_service(self, service: NaloGoService) -> None:
|
||||
"""Установить сервис NaloGO."""
|
||||
self._nalogo_service = service
|
||||
|
||||
def set_bot(self, bot: Bot) -> None:
|
||||
"""Установить бота для отправки уведомлений."""
|
||||
self._bot = bot
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""Проверка, запущен ли сервис."""
|
||||
return self._running and self._task is not None and not self._task.done()
|
||||
|
||||
@property
|
||||
def _check_interval(self) -> int:
|
||||
"""Интервал проверки очереди в секундах."""
|
||||
return getattr(settings, "NALOGO_QUEUE_CHECK_INTERVAL", 300)
|
||||
|
||||
@property
|
||||
def _receipt_delay(self) -> int:
|
||||
"""Задержка между отправкой чеков в секундах."""
|
||||
return getattr(settings, "NALOGO_QUEUE_RECEIPT_DELAY", 3)
|
||||
|
||||
@property
|
||||
def _max_attempts(self) -> int:
|
||||
"""Максимальное количество попыток отправки чека."""
|
||||
return getattr(settings, "NALOGO_QUEUE_MAX_ATTEMPTS", 10)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Запустить фоновую обработку очереди."""
|
||||
if not self._nalogo_service or not self._nalogo_service.configured:
|
||||
logger.info("NaloGO не настроен, сервис очереди чеков не запущен")
|
||||
return
|
||||
|
||||
if self.is_running():
|
||||
logger.warning("Сервис очереди чеков уже запущен")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._process_queue_loop())
|
||||
logger.info(
|
||||
f"Сервис очереди чеков NaloGO запущен "
|
||||
f"(интервал: {self._check_interval}с, задержка между чеками: {self._receipt_delay}с)"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Остановить фоновую обработку."""
|
||||
self._running = False
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
logger.info("Сервис очереди чеков NaloGO остановлен")
|
||||
|
||||
async def _send_admin_notification(self, message: str, skip_cooldown: bool = False) -> None:
|
||||
"""Отправить уведомление админам о чеках."""
|
||||
if not self._bot:
|
||||
return
|
||||
|
||||
chat_id = settings.get_admin_notifications_chat_id()
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
topic_id = settings.ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
|
||||
|
||||
# Проверяем cooldown (можно пропустить для важных уведомлений)
|
||||
if not skip_cooldown:
|
||||
now = datetime.now()
|
||||
if self._last_notification_time:
|
||||
if now - self._last_notification_time < self._notification_cooldown:
|
||||
logger.debug("Уведомление о чеках пропущено (cooldown)")
|
||||
return
|
||||
|
||||
try:
|
||||
await self._bot.send_message(
|
||||
chat_id=chat_id,
|
||||
message_thread_id=topic_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
self._last_notification_time = datetime.now()
|
||||
logger.info("Отправлено уведомление о чеках NaloGO")
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка отправки уведомления о чеках: {error}")
|
||||
|
||||
async def _process_queue_loop(self) -> None:
|
||||
"""Основной цикл обработки очереди."""
|
||||
while self._running:
|
||||
try:
|
||||
await self._process_pending_receipts()
|
||||
except Exception as error:
|
||||
logger.error(f"Ошибка в цикле обработки очереди чеков: {error}")
|
||||
|
||||
await asyncio.sleep(self._check_interval)
|
||||
|
||||
async def _process_pending_receipts(self) -> None:
|
||||
"""Обработать все ожидающие чеки в очереди."""
|
||||
if not self._nalogo_service:
|
||||
return
|
||||
|
||||
queue_length = await self._nalogo_service.get_queue_length()
|
||||
if queue_length == 0:
|
||||
return
|
||||
|
||||
logger.info(f"Начинаем обработку очереди чеков: {queue_length} шт.")
|
||||
self._had_pending_receipts = True
|
||||
|
||||
processed = 0
|
||||
failed = 0
|
||||
skipped = 0
|
||||
total_processed_amount = 0.0
|
||||
service_unavailable = False
|
||||
|
||||
while True:
|
||||
receipt_data = await self._nalogo_service.pop_receipt_from_queue()
|
||||
if not receipt_data:
|
||||
break
|
||||
|
||||
attempts = receipt_data.get("attempts", 0)
|
||||
payment_id = receipt_data.get("payment_id", "unknown")
|
||||
amount = receipt_data.get("amount", 0)
|
||||
|
||||
# Логируем количество попыток (чек никогда не удаляется из очереди)
|
||||
if attempts >= 10:
|
||||
logger.warning(
|
||||
f"Чек {payment_id} уже {attempts} попыток, продолжаем пытаться..."
|
||||
)
|
||||
|
||||
# Пытаемся отправить чек
|
||||
try:
|
||||
# Восстанавливаем описание из сохранённых данных
|
||||
telegram_user_id = receipt_data.get("telegram_user_id")
|
||||
amount_kopeks = receipt_data.get("amount_kopeks")
|
||||
|
||||
# Извлекаем время оплаты из очереди (чтобы чек был с правильным временем)
|
||||
operation_time = None
|
||||
created_at_str = receipt_data.get("created_at")
|
||||
if created_at_str:
|
||||
try:
|
||||
operation_time = isoparse(created_at_str)
|
||||
except (ValueError, TypeError) as parse_error:
|
||||
logger.warning(
|
||||
f"Не удалось распарсить created_at '{created_at_str}': {parse_error}"
|
||||
)
|
||||
|
||||
# Формируем описание заново из настроек (если есть данные)
|
||||
if amount_kopeks is not None:
|
||||
receipt_name = settings.get_balance_payment_description(
|
||||
amount_kopeks, telegram_user_id
|
||||
)
|
||||
else:
|
||||
# Fallback на сохранённое имя
|
||||
receipt_name = receipt_data.get(
|
||||
"name",
|
||||
settings.get_balance_payment_description(int(amount * 100), telegram_user_id)
|
||||
)
|
||||
|
||||
receipt_uuid = await self._nalogo_service.create_receipt(
|
||||
name=receipt_name,
|
||||
amount=amount,
|
||||
quantity=receipt_data.get("quantity", 1),
|
||||
client_info=receipt_data.get("client_info"),
|
||||
payment_id=payment_id,
|
||||
queue_on_failure=False, # Не добавлять в очередь повторно автоматически
|
||||
telegram_user_id=telegram_user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
operation_time=operation_time, # Время оплаты, а не отправки
|
||||
)
|
||||
|
||||
if receipt_uuid:
|
||||
processed += 1
|
||||
total_processed_amount += amount
|
||||
|
||||
# Удаляем метку "в очереди" (чек создан успешно)
|
||||
if payment_id:
|
||||
queued_key = f"nalogo:queued:{payment_id}"
|
||||
await cache.delete(queued_key)
|
||||
|
||||
logger.info(
|
||||
f"Чек из очереди успешно создан: {receipt_uuid} "
|
||||
f"(payment_id={payment_id}, попытка {attempts + 1})"
|
||||
)
|
||||
else:
|
||||
# Вернуть в очередь с увеличенным счетчиком попыток
|
||||
await self._nalogo_service.requeue_receipt(receipt_data)
|
||||
failed += 1
|
||||
service_unavailable = True
|
||||
logger.warning(
|
||||
f"Не удалось создать чек из очереди (payment_id={payment_id}), "
|
||||
f"возвращен в очередь (попытка {attempts + 1}/{self._max_attempts})"
|
||||
)
|
||||
# Если сервис недоступен, прекращаем попытки до следующего цикла
|
||||
break
|
||||
|
||||
except Exception as error:
|
||||
await self._nalogo_service.requeue_receipt(receipt_data)
|
||||
failed += 1
|
||||
logger.error(
|
||||
f"Ошибка при создании чека из очереди (payment_id={payment_id}): {error}"
|
||||
)
|
||||
# Прекращаем попытки при ошибке
|
||||
break
|
||||
|
||||
# Задержка между чеками чтобы не долбить API
|
||||
await asyncio.sleep(self._receipt_delay)
|
||||
|
||||
if processed > 0 or failed > 0 or skipped > 0:
|
||||
logger.info(
|
||||
f"Обработка очереди завершена: "
|
||||
f"успешно={processed}, неудачно={failed}, пропущено={skipped}"
|
||||
)
|
||||
|
||||
# Проверяем остаток в очереди
|
||||
remaining = await self._nalogo_service.get_queue_length()
|
||||
|
||||
# Отправляем уведомление если есть проблемы
|
||||
if service_unavailable or failed > 0:
|
||||
if remaining > 0:
|
||||
queued = await self._nalogo_service.get_queued_receipts()
|
||||
total_queued_amount = sum(r.get("amount", 0) for r in queued)
|
||||
|
||||
message = (
|
||||
f"<b>⚠️ Проблема с отправкой чеков NaloGO</b>\n\n"
|
||||
f"Сервис nalog.ru временно недоступен.\n\n"
|
||||
f"📋 <b>В очереди:</b> {remaining} чек(ов)\n"
|
||||
f"💰 <b>На сумму:</b> {total_queued_amount:,.2f} ₽\n\n"
|
||||
f"Чеки будут отправлены автоматически когда сервис восстановится."
|
||||
)
|
||||
await self._send_admin_notification(message)
|
||||
|
||||
# Уведомление об успешной разгрузке очереди
|
||||
elif remaining == 0 and self._had_pending_receipts and processed > 0:
|
||||
self._had_pending_receipts = False
|
||||
message = (
|
||||
f"<b>✅ Очередь чеков NaloGO разгружена</b>\n\n"
|
||||
f"Все отложенные чеки успешно отправлены!\n\n"
|
||||
f"📋 <b>Отправлено:</b> {processed} чек(ов)\n"
|
||||
f"💰 <b>На сумму:</b> {total_processed_amount:,.2f} ₽"
|
||||
)
|
||||
await self._send_admin_notification(message, skip_cooldown=True)
|
||||
|
||||
async def force_process(self) -> dict:
|
||||
"""Принудительно обработать очередь (для ручного запуска)."""
|
||||
if not self._nalogo_service:
|
||||
return {"error": "NaloGO сервис не настроен"}
|
||||
|
||||
queue_length = await self._nalogo_service.get_queue_length()
|
||||
if queue_length == 0:
|
||||
return {"message": "Очередь пуста", "processed": 0}
|
||||
|
||||
await self._process_pending_receipts()
|
||||
new_length = await self._nalogo_service.get_queue_length()
|
||||
|
||||
return {
|
||||
"message": "Обработка завершена",
|
||||
"was_in_queue": queue_length,
|
||||
"remaining": new_length,
|
||||
"processed": queue_length - new_length,
|
||||
}
|
||||
|
||||
async def get_status(self) -> dict:
|
||||
"""Получить статус сервиса и очереди."""
|
||||
queue_length = 0
|
||||
total_amount = 0.0
|
||||
queued_receipts = []
|
||||
|
||||
if self._nalogo_service:
|
||||
queue_length = await self._nalogo_service.get_queue_length()
|
||||
if queue_length > 0:
|
||||
queued_receipts = await self._nalogo_service.get_queued_receipts()
|
||||
total_amount = sum(r.get("amount", 0) for r in queued_receipts)
|
||||
|
||||
return {
|
||||
"running": self.is_running(),
|
||||
"check_interval_seconds": self._check_interval,
|
||||
"receipt_delay_seconds": self._receipt_delay,
|
||||
"queue_length": queue_length,
|
||||
"total_amount": total_amount,
|
||||
"max_attempts": self._max_attempts,
|
||||
"queued_receipts": queued_receipts[:10], # Показываем только первые 10
|
||||
}
|
||||
|
||||
|
||||
# Глобальный экземпляр сервиса
|
||||
nalogo_queue_service = NalogoQueueService()
|
||||
@@ -1,14 +1,19 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from typing import Optional, Dict, Any, List
|
||||
from decimal import Decimal
|
||||
|
||||
from nalogo import Client
|
||||
from nalogo.dto.income import IncomeClient, IncomeType
|
||||
# Используем локальную исправленную версию библиотеки
|
||||
from app.lib.nalogo import Client
|
||||
from app.lib.nalogo.dto.income import IncomeClient, IncomeType, MOSCOW_TZ
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
NALOGO_QUEUE_KEY = "nalogo:receipt_queue"
|
||||
|
||||
|
||||
class NaloGoService:
|
||||
"""Сервис для работы с API NaloGO (налоговая служба самозанятых)."""
|
||||
@@ -49,6 +54,85 @@ class NaloGoService:
|
||||
)
|
||||
self.configured = False
|
||||
|
||||
@staticmethod
|
||||
def _is_service_unavailable(error: Exception) -> bool:
|
||||
"""Проверяет, является ли ошибка временной недоступностью сервиса."""
|
||||
error_str = str(error).lower()
|
||||
error_type = type(error).__name__.lower()
|
||||
return (
|
||||
"503" in error_str
|
||||
or "500" in error_str
|
||||
or "internal server error" in error_str
|
||||
or "внутренняя ошибка" in error_str
|
||||
or "service temporarily unavailable" in error_str
|
||||
or "service unavailable" in error_str
|
||||
or "ведутся работы" in error_str
|
||||
or ("health" in error_str and "false" in error_str)
|
||||
# Таймауты и сетевые ошибки — временные проблемы
|
||||
or "timeout" in error_type
|
||||
or "timeout" in error_str
|
||||
or "readtimeout" in error_type
|
||||
or "connecttimeout" in error_type
|
||||
or "connectionerror" in error_type
|
||||
or "connecterror" in error_type
|
||||
)
|
||||
|
||||
async def _queue_receipt(
|
||||
self,
|
||||
name: str,
|
||||
amount: float,
|
||||
quantity: int,
|
||||
client_info: Optional[Dict[str, Any]],
|
||||
payment_id: Optional[str] = None,
|
||||
telegram_user_id: Optional[int] = None,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Добавить чек в очередь для отложенной отправки."""
|
||||
if payment_id:
|
||||
# Защита от дубликатов: проверяем не был ли чек уже создан
|
||||
created_key = f"nalogo:created:{payment_id}"
|
||||
already_created = await cache.get(created_key)
|
||||
if already_created:
|
||||
logger.info(
|
||||
f"Чек для payment_id={payment_id} уже создан ({already_created}), "
|
||||
"не добавляем в очередь"
|
||||
)
|
||||
return False
|
||||
|
||||
# Проверяем не в очереди ли уже
|
||||
queued_key = f"nalogo:queued:{payment_id}"
|
||||
already_queued = await cache.get(queued_key)
|
||||
if already_queued:
|
||||
logger.info(
|
||||
f"Чек для payment_id={payment_id} уже в очереди, пропускаем дубликат"
|
||||
)
|
||||
return False
|
||||
|
||||
receipt_data = {
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"quantity": quantity,
|
||||
"client_info": client_info,
|
||||
"payment_id": payment_id,
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"amount_kopeks": amount_kopeks,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"attempts": 0,
|
||||
}
|
||||
success = await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
|
||||
if success:
|
||||
# Помечаем что чек в очереди (TTL 7 дней)
|
||||
if payment_id:
|
||||
queued_key = f"nalogo:queued:{payment_id}"
|
||||
await cache.set(queued_key, "queued", expire=7 * 24 * 3600)
|
||||
|
||||
queue_len = await cache.llen(NALOGO_QUEUE_KEY)
|
||||
logger.info(
|
||||
f"Чек добавлен в очередь (payment_id={payment_id}, "
|
||||
f"сумма={amount}₽, в очереди: {queue_len})"
|
||||
)
|
||||
return success
|
||||
|
||||
async def authenticate(self) -> bool:
|
||||
"""Аутентификация в сервисе NaloGO."""
|
||||
if not self.configured:
|
||||
@@ -60,10 +144,27 @@ class NaloGoService:
|
||||
logger.info("Успешная аутентификация в NaloGO")
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
|
||||
if self._is_service_unavailable(error):
|
||||
logger.warning(
|
||||
"NaloGO временно недоступен (техработы): %s",
|
||||
str(error)[:200]
|
||||
)
|
||||
else:
|
||||
logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
|
||||
return False
|
||||
|
||||
async def create_receipt(self, name: str, amount: float, quantity: int = 1, client_info: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
async def create_receipt(
|
||||
self,
|
||||
name: str,
|
||||
amount: float,
|
||||
quantity: int = 1,
|
||||
client_info: Optional[Dict[str, Any]] = None,
|
||||
payment_id: Optional[str] = None,
|
||||
queue_on_failure: bool = True,
|
||||
telegram_user_id: Optional[int] = None,
|
||||
amount_kopeks: Optional[int] = None,
|
||||
operation_time: Optional[datetime] = None,
|
||||
) -> Optional[str]:
|
||||
"""Создание чека о доходе.
|
||||
|
||||
Args:
|
||||
@@ -71,6 +172,11 @@ class NaloGoService:
|
||||
amount: Сумма в рублях
|
||||
quantity: Количество
|
||||
client_info: Информация о клиенте (опционально)
|
||||
payment_id: ID платежа для логирования
|
||||
queue_on_failure: Добавить в очередь при временной недоступности
|
||||
telegram_user_id: Telegram ID пользователя для формирования описания
|
||||
amount_kopeks: Сумма в копейках для формирования описания
|
||||
operation_time: Время операции (по умолчанию текущее)
|
||||
|
||||
Returns:
|
||||
UUID чека или None при ошибке
|
||||
@@ -79,11 +185,28 @@ class NaloGoService:
|
||||
logger.warning("NaloGO не настроен, чек не создан")
|
||||
return None
|
||||
|
||||
# Защита от дублей: проверяем не был ли уже создан чек для этого payment_id
|
||||
if payment_id:
|
||||
created_key = f"nalogo:created:{payment_id}"
|
||||
already_created = await cache.get(created_key)
|
||||
if already_created:
|
||||
logger.info(
|
||||
f"Чек для payment_id={payment_id} уже был создан ({already_created}), "
|
||||
"пропускаем повторное создание"
|
||||
)
|
||||
return already_created # Возвращаем ранее созданный uuid
|
||||
|
||||
try:
|
||||
# Аутентифицируемся, если нужно
|
||||
if not hasattr(self.client, '_access_token') or not self.client._access_token:
|
||||
auth_success = await self.authenticate()
|
||||
if not auth_success:
|
||||
# Если сервис недоступен — добавляем в очередь
|
||||
if queue_on_failure:
|
||||
await self._queue_receipt(
|
||||
name, amount, quantity, client_info, payment_id,
|
||||
telegram_user_id, amount_kopeks
|
||||
)
|
||||
return None
|
||||
|
||||
income_api = self.client.income()
|
||||
@@ -98,21 +221,103 @@ class NaloGoService:
|
||||
inn=client_info.get("inn")
|
||||
)
|
||||
|
||||
# Используем переданное время операции или текущее
|
||||
result = await income_api.create(
|
||||
name=name,
|
||||
amount=Decimal(str(amount)),
|
||||
quantity=quantity,
|
||||
client=income_client
|
||||
operation_time=operation_time,
|
||||
client=income_client,
|
||||
)
|
||||
|
||||
receipt_uuid = result.get("approvedReceiptUuid")
|
||||
if receipt_uuid:
|
||||
logger.info(f"Чек создан успешно: {receipt_uuid} на сумму {amount}₽")
|
||||
|
||||
# Сохраняем в Redis чтобы предотвратить дубли (TTL 30 дней)
|
||||
if payment_id:
|
||||
created_key = f"nalogo:created:{payment_id}"
|
||||
await cache.set(created_key, receipt_uuid, expire=30 * 24 * 3600)
|
||||
|
||||
return receipt_uuid
|
||||
else:
|
||||
logger.error(f"Ошибка создания чека: {result}")
|
||||
return None
|
||||
|
||||
except Exception as error:
|
||||
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
|
||||
if self._is_service_unavailable(error):
|
||||
logger.warning(
|
||||
"NaloGO временно недоступен, чек будет отправлен позже "
|
||||
f"(payment_id={payment_id}, сумма={amount}₽)"
|
||||
)
|
||||
if queue_on_failure:
|
||||
await self._queue_receipt(
|
||||
name, amount, quantity, client_info, payment_id,
|
||||
telegram_user_id, amount_kopeks
|
||||
)
|
||||
else:
|
||||
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
|
||||
return None
|
||||
|
||||
async def get_queue_length(self) -> int:
|
||||
"""Получить количество чеков в очереди."""
|
||||
return await cache.llen(NALOGO_QUEUE_KEY)
|
||||
|
||||
async def get_queued_receipts(self) -> list:
|
||||
"""Получить список чеков в очереди (без удаления)."""
|
||||
return await cache.lrange(NALOGO_QUEUE_KEY)
|
||||
|
||||
async def pop_receipt_from_queue(self) -> Optional[Dict[str, Any]]:
|
||||
"""Извлечь следующий чек из очереди."""
|
||||
return await cache.rpop(NALOGO_QUEUE_KEY)
|
||||
|
||||
async def requeue_receipt(self, receipt_data: Dict[str, Any]) -> bool:
|
||||
"""Вернуть чек обратно в очередь (при неудачной отправке)."""
|
||||
receipt_data["attempts"] = receipt_data.get("attempts", 0) + 1
|
||||
return await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
|
||||
|
||||
async def get_incomes(
|
||||
self,
|
||||
from_date: Optional[date] = None,
|
||||
to_date: Optional[date] = None,
|
||||
limit: int = 100,
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Получить список доходов (чеков) за период.
|
||||
|
||||
Args:
|
||||
from_date: Начало периода (по умолчанию 30 дней назад)
|
||||
to_date: Конец периода (по умолчанию сегодня)
|
||||
limit: Максимальное количество записей
|
||||
|
||||
Returns:
|
||||
Список чеков с информацией, или None при ошибке
|
||||
"""
|
||||
if not self.configured:
|
||||
logger.warning("NaloGO не настроен, невозможно получить список доходов")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Аутентифицируемся если нужно
|
||||
if not hasattr(self.client, '_access_token') or not self.client._access_token:
|
||||
auth_success = await self.authenticate()
|
||||
if not auth_success:
|
||||
return []
|
||||
|
||||
income_api = self.client.income()
|
||||
result = await income_api.get_list(
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# API возвращает структуру с полем content или items
|
||||
incomes = result.get("content", result.get("items", []))
|
||||
logger.info(f"Получено {len(incomes)} доходов из NaloGO")
|
||||
return incomes
|
||||
|
||||
except Exception as error:
|
||||
if self._is_service_unavailable(error):
|
||||
logger.warning(f"NaloGO временно недоступен: {error}")
|
||||
else:
|
||||
logger.error(f"Ошибка получения списка доходов: {error}", exc_info=True)
|
||||
return None # None = ошибка, [] = нет чеков
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Mixin for integrating CloudPayments into the payment service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.cloudpayments_service import CloudPaymentsAPIError, CloudPaymentsService
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
|
||||
|
||||
class CloudPaymentsPaymentMixin:
|
||||
"""Encapsulates creation and webhook handling for CloudPayments."""
|
||||
|
||||
async def create_cloudpayments_payment(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount_kopeks: int,
|
||||
description: str,
|
||||
*,
|
||||
telegram_id: int,
|
||||
language: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Create a CloudPayments payment and return payment link info.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Internal user ID
|
||||
amount_kopeks: Payment amount in kopeks
|
||||
description: Payment description
|
||||
telegram_id: User's Telegram ID
|
||||
language: User's language
|
||||
email: User's email (optional)
|
||||
|
||||
Returns:
|
||||
Dict with payment_url and invoice_id, or None on error
|
||||
"""
|
||||
if not getattr(self, "cloudpayments_service", None):
|
||||
logger.error("CloudPayments service is not initialised")
|
||||
return None
|
||||
|
||||
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма CloudPayments меньше минимальной: %s < %s",
|
||||
amount_kopeks,
|
||||
settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
|
||||
logger.warning(
|
||||
"Сумма CloudPayments больше максимальной: %s > %s",
|
||||
amount_kopeks,
|
||||
settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS,
|
||||
)
|
||||
return None
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
# Generate unique invoice ID
|
||||
invoice_id = self.cloudpayments_service.generate_invoice_id(telegram_id)
|
||||
|
||||
try:
|
||||
# Create payment order via CloudPayments API
|
||||
payment_url = await self.cloudpayments_service.generate_payment_link(
|
||||
telegram_id=telegram_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
invoice_id=invoice_id,
|
||||
description=description,
|
||||
email=email,
|
||||
)
|
||||
except CloudPaymentsAPIError as error:
|
||||
logger.error("Ошибка создания CloudPayments платежа: %s", error)
|
||||
return None
|
||||
except Exception as error:
|
||||
logger.exception("Непредвиденная ошибка при создании CloudPayments платежа: %s", error)
|
||||
return None
|
||||
|
||||
metadata = {
|
||||
"language": language or settings.DEFAULT_LANGUAGE,
|
||||
"telegram_id": telegram_id,
|
||||
}
|
||||
|
||||
# Create local payment record
|
||||
local_payment = await payment_module.create_cloudpayments_payment(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
invoice_id=invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=description,
|
||||
payment_url=payment_url,
|
||||
metadata=metadata,
|
||||
test_mode=settings.CLOUDPAYMENTS_TEST_MODE,
|
||||
)
|
||||
|
||||
if not local_payment:
|
||||
logger.error("Не удалось создать локальную запись CloudPayments платежа")
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Создан CloudPayments платёж: invoice=%s, amount=%s₽, user=%s",
|
||||
invoice_id,
|
||||
amount_kopeks / 100,
|
||||
user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"payment_url": payment_url,
|
||||
"invoice_id": invoice_id,
|
||||
"payment_id": local_payment.id,
|
||||
}
|
||||
|
||||
async def process_cloudpayments_pay_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
webhook_data: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Process CloudPayments Pay webhook (successful payment).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
webhook_data: Parsed webhook data
|
||||
|
||||
Returns:
|
||||
True if payment was processed successfully
|
||||
"""
|
||||
invoice_id = webhook_data.get("invoice_id")
|
||||
transaction_id_cp = webhook_data.get("transaction_id")
|
||||
amount = webhook_data.get("amount", 0)
|
||||
amount_kopeks = int(amount * 100)
|
||||
account_id = webhook_data.get("account_id", "")
|
||||
token = webhook_data.get("token")
|
||||
test_mode = webhook_data.get("test_mode", False)
|
||||
|
||||
if not invoice_id:
|
||||
logger.error("CloudPayments webhook без invoice_id")
|
||||
return False
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
# Find existing payment record
|
||||
payment = await payment_module.get_cloudpayments_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
if not payment:
|
||||
logger.warning(
|
||||
"CloudPayments платёж не найден: invoice=%s, создаём новый",
|
||||
invoice_id,
|
||||
)
|
||||
# Try to extract telegram_id from account_id
|
||||
try:
|
||||
telegram_id = int(account_id) if account_id else None
|
||||
except ValueError:
|
||||
telegram_id = None
|
||||
|
||||
if not telegram_id:
|
||||
logger.error("Не удалось определить telegram_id из account_id: %s", account_id)
|
||||
return False
|
||||
|
||||
# Get user by telegram_id
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user:
|
||||
logger.error("Пользователь не найден: telegram_id=%s", telegram_id)
|
||||
return False
|
||||
|
||||
# Create payment record
|
||||
payment = await payment_module.create_cloudpayments_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
invoice_id=invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
test_mode=test_mode,
|
||||
)
|
||||
|
||||
if not payment:
|
||||
logger.error("Не удалось создать запись платежа")
|
||||
return False
|
||||
|
||||
# Check if already processed
|
||||
if payment.is_paid:
|
||||
logger.info("CloudPayments платёж уже обработан: invoice=%s", invoice_id)
|
||||
return True
|
||||
|
||||
# Update payment record
|
||||
payment.transaction_id_cp = transaction_id_cp
|
||||
payment.status = "completed"
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.utcnow()
|
||||
payment.token = token
|
||||
payment.card_first_six = webhook_data.get("card_first_six")
|
||||
payment.card_last_four = webhook_data.get("card_last_four")
|
||||
payment.card_type = webhook_data.get("card_type")
|
||||
payment.card_exp_date = webhook_data.get("card_exp_date")
|
||||
payment.email = webhook_data.get("email")
|
||||
payment.test_mode = test_mode
|
||||
payment.callback_payload = webhook_data
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Get user
|
||||
from app.database.crud.user import get_user_by_id, add_user_balance
|
||||
user = await get_user_by_id(db, payment.user_id)
|
||||
|
||||
if not user:
|
||||
logger.error("Пользователь не найден: id=%s", payment.user_id)
|
||||
return False
|
||||
|
||||
# Add balance
|
||||
await add_user_balance(db, user.id, amount_kopeks)
|
||||
|
||||
# Create transaction record
|
||||
from app.database.crud.transaction import create_transaction
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type_=TransactionType.DEPOSIT,
|
||||
amount_kopeks=amount_kopeks,
|
||||
description=payment.description or settings.CLOUDPAYMENTS_DESCRIPTION,
|
||||
payment_method=PaymentMethod.CLOUDPAYMENTS,
|
||||
external_id=str(transaction_id_cp) if transaction_id_cp else invoice_id,
|
||||
is_completed=True,
|
||||
)
|
||||
|
||||
payment.transaction_id = transaction.id
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"CloudPayments платёж успешно обработан: invoice=%s, amount=%s₽, user=%s",
|
||||
invoice_id,
|
||||
amount_kopeks / 100,
|
||||
user.telegram_id,
|
||||
)
|
||||
|
||||
# Send notification to user
|
||||
try:
|
||||
await self._send_cloudpayments_success_notification(
|
||||
user=user,
|
||||
amount_kopeks=amount_kopeks,
|
||||
transaction=transaction,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка отправки уведомления CloudPayments: %s", error)
|
||||
|
||||
# Auto-purchase if enabled
|
||||
auto_purchase_success = False
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка автопокупки после CloudPayments: %s", error)
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error)
|
||||
|
||||
return True
|
||||
|
||||
async def process_cloudpayments_fail_webhook(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
webhook_data: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""
|
||||
Process CloudPayments Fail webhook (failed payment).
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
webhook_data: Parsed webhook data
|
||||
|
||||
Returns:
|
||||
True if processed successfully
|
||||
"""
|
||||
invoice_id = webhook_data.get("invoice_id")
|
||||
reason = webhook_data.get("reason", "Unknown")
|
||||
reason_code = webhook_data.get("reason_code")
|
||||
card_holder_message = webhook_data.get("card_holder_message", reason)
|
||||
account_id = webhook_data.get("account_id", "")
|
||||
|
||||
if not invoice_id:
|
||||
logger.warning("CloudPayments fail webhook без invoice_id")
|
||||
return True
|
||||
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
# Find payment record
|
||||
payment = await payment_module.get_cloudpayments_payment_by_invoice_id(db, invoice_id)
|
||||
|
||||
if payment:
|
||||
payment.status = "failed"
|
||||
payment.callback_payload = webhook_data
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"CloudPayments платёж неуспешен: invoice=%s, reason=%s (code=%s)",
|
||||
invoice_id,
|
||||
reason,
|
||||
reason_code,
|
||||
)
|
||||
|
||||
# Notify user about failed payment
|
||||
try:
|
||||
telegram_id = int(account_id) if account_id else None
|
||||
if telegram_id:
|
||||
await self._send_cloudpayments_fail_notification(
|
||||
telegram_id=telegram_id,
|
||||
message=card_holder_message,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception("Ошибка отправки уведомления о неуспешном платеже: %s", error)
|
||||
|
||||
return True
|
||||
|
||||
async def _send_cloudpayments_success_notification(
|
||||
self,
|
||||
user: Any,
|
||||
amount_kopeks: int,
|
||||
transaction: Any,
|
||||
) -> None:
|
||||
"""Send success notification to user via Telegram."""
|
||||
from app.bot import bot
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
if not bot:
|
||||
return
|
||||
|
||||
texts = get_texts(user.language)
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
amount_rub = amount_kopeks / 100
|
||||
new_balance = user.balance_kopeks / 100
|
||||
|
||||
message = texts.t(
|
||||
"PAYMENT_SUCCESS_CLOUDPAYMENTS",
|
||||
"✅ <b>Оплата получена!</b>\n\n"
|
||||
"💰 Сумма: {amount}₽\n"
|
||||
"💳 Способ: CloudPayments\n"
|
||||
"💵 Баланс: {balance}₽\n\n"
|
||||
"Спасибо за пополнение!",
|
||||
).format(
|
||||
amount=f"{amount_rub:.2f}",
|
||||
balance=f"{new_balance:.2f}",
|
||||
)
|
||||
|
||||
if referrer_info:
|
||||
message += f"\n\n{referrer_info}"
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning("Не удалось отправить уведомление пользователю %s: %s", user.telegram_id, error)
|
||||
|
||||
async def _send_cloudpayments_fail_notification(
|
||||
self,
|
||||
telegram_id: int,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Send failure notification to user via Telegram."""
|
||||
from app.bot import bot
|
||||
|
||||
if not bot:
|
||||
return
|
||||
|
||||
text = f"❌ <b>Оплата не прошла</b>\n\n{message}"
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
text=text,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning("Не удалось отправить уведомление пользователю %s: %s", telegram_id, error)
|
||||
|
||||
async def get_cloudpayments_payment_status(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
local_payment_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Check CloudPayments payment status via API.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
local_payment_id: Internal payment ID
|
||||
|
||||
Returns:
|
||||
Dict with payment info or None if not found
|
||||
"""
|
||||
payment_module = import_module("app.services.payment_service")
|
||||
|
||||
# Get local payment record
|
||||
payment = await payment_module.get_cloudpayments_payment_by_id(db, local_payment_id)
|
||||
if not payment:
|
||||
logger.warning("CloudPayments payment not found: id=%s", local_payment_id)
|
||||
return None
|
||||
|
||||
# If already paid, return current state
|
||||
if payment.is_paid:
|
||||
return {"payment": payment, "status": "completed"}
|
||||
|
||||
# Check with CloudPayments API
|
||||
if not getattr(self, "cloudpayments_service", None):
|
||||
logger.warning("CloudPayments service not initialized")
|
||||
return {"payment": payment, "status": payment.status}
|
||||
|
||||
try:
|
||||
# Try to find payment by invoice_id
|
||||
api_response = await self.cloudpayments_service.find_payment(payment.invoice_id)
|
||||
|
||||
if not api_response.get("Success"):
|
||||
logger.debug(
|
||||
"CloudPayments API: payment not found or error for invoice=%s",
|
||||
payment.invoice_id,
|
||||
)
|
||||
return {"payment": payment, "status": payment.status}
|
||||
|
||||
model = api_response.get("Model", {})
|
||||
api_status = model.get("Status", "")
|
||||
transaction_id_cp = model.get("TransactionId")
|
||||
|
||||
# Update local record if status changed
|
||||
if api_status == "Completed" and not payment.is_paid:
|
||||
# Payment completed - process it
|
||||
webhook_data = {
|
||||
"invoice_id": payment.invoice_id,
|
||||
"transaction_id": transaction_id_cp,
|
||||
"amount": model.get("Amount", 0),
|
||||
"account_id": model.get("AccountId", ""),
|
||||
"token": model.get("Token"),
|
||||
"card_first_six": model.get("CardFirstSix"),
|
||||
"card_last_four": model.get("CardLastFour"),
|
||||
"card_type": model.get("CardType"),
|
||||
"card_exp_date": model.get("CardExpDate"),
|
||||
"email": model.get("Email"),
|
||||
"test_mode": model.get("TestMode", False),
|
||||
"status": api_status,
|
||||
}
|
||||
await self.process_cloudpayments_pay_webhook(db, webhook_data)
|
||||
await db.refresh(payment)
|
||||
|
||||
elif api_status in ("Declined", "Cancelled") and payment.status not in ("failed", "cancelled"):
|
||||
payment.status = "failed"
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
return {"payment": payment, "status": payment.status}
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Error checking CloudPayments payment status: id=%s, error=%s",
|
||||
local_payment_id,
|
||||
error,
|
||||
)
|
||||
return {"payment": payment, "status": payment.status}
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -25,8 +24,7 @@ from app.services.subscription_checkout_service import (
|
||||
)
|
||||
from app.services.user_cart_service import user_cart_service
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
|
||||
|
||||
class PaymentCommonMixin:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Mixin с логикой обработки платежей CryptoBot."""
|
||||
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
@@ -14,6 +14,7 @@ from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.services.subscription_renewal_service import (
|
||||
@@ -27,8 +28,7 @@ from app.services.subscription_renewal_service import (
|
||||
)
|
||||
from app.utils.currency_converter import currency_converter
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
|
||||
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
@@ -376,6 +376,22 @@ class CryptoBotPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=bot_instance,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and bot_instance:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
@@ -14,8 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
|
||||
|
||||
class HeleketPaymentMixin:
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from importlib import import_module
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -12,11 +11,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
|
||||
|
||||
class MulenPayPaymentMixin:
|
||||
@@ -396,11 +395,23 @@ class MulenPayPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
import uuid
|
||||
@@ -14,11 +13,11 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.pal24_service import Pal24APIError
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
|
||||
|
||||
class Pal24PaymentMixin:
|
||||
@@ -499,6 +498,18 @@ class Pal24PaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from importlib import import_module
|
||||
@@ -14,11 +13,11 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.platega_service import PlategaService
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.user_utils import format_referrer_info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
|
||||
|
||||
class PlategaPaymentMixin:
|
||||
@@ -360,11 +359,12 @@ class PlategaPaymentMixin:
|
||||
PaymentMethod.PLATEGA,
|
||||
)
|
||||
|
||||
platega_name = settings.get_platega_display_name()
|
||||
method_display = settings.get_platega_method_display_name(payment.payment_method_code)
|
||||
description = (
|
||||
f"Пополнение через Platega ({method_display})"
|
||||
f"Пополнение через {platega_name} ({method_display})"
|
||||
if method_display
|
||||
else "Пополнение через Platega"
|
||||
else f"Пополнение через {platega_name}"
|
||||
)
|
||||
|
||||
transaction = existing_transaction
|
||||
@@ -484,6 +484,18 @@ class PlategaPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
await auto_activate_subscription_after_topup(db, user)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
"Ошибка умной автоактивации для пользователя %s: %s",
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if has_saved_cart and getattr(self, "bot", None):
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user