Compare commits
257 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e375252d65 | |||
| fa78fa6d09 | |||
| 11f8af003f | |||
| 11ef714e0d | |||
| 909a4039c4 | |||
| bf646112df | |||
| 8a6650e57c | |||
| 25e8c9f8fc | |||
| 1f0fef114b | |||
| be6036e879 | |||
| bba85a309a | |||
| 448be6e512 | |||
| 871ceb866c | |||
| 8e61fe4774 | |||
| d4dfa235e5 | |||
| 97ec39aa80 | |||
| 61a97220d3 | |||
| 2d04f2aa28 | |||
| d6e79161e7 | |||
| 45fd543206 | |||
| ba0a5e9abd | |||
| d712ab8301 | |||
| 1e2a7e3096 | |||
| 64a684cd2f | |||
| e4c207ecff | |||
| 80914c1af7 | |||
| e1822800ab | |||
| 68773b7e77 | |||
| 10538e7351 | |||
| a9687912df | |||
| 46c1a69456 | |||
| bf2b2f1c56 | |||
| 9ac6da490d | |||
| ad87c5fb5e | |||
| 7ac73e5745 | |||
| 61be89743d | |||
| d174d9a927 | |||
| 4048aebb9f | |||
| bfd66c42c1 | |||
| 351c95bac1 | |||
| 1d43ae5e25 | |||
| 476b89fe8e | |||
| 14e13177b5 | |||
| 760c833b74 | |||
| 1a476c49c1 | |||
| 454b83138e | |||
| 2de438426a | |||
| 6039db997c | |||
| 940959c951 | |||
| e688110129 | |||
| 57dc1ff47f | |||
| fc42916b10 | |||
| 5893874776 | |||
| 60305d8d5b | |||
| 07ef3b46d9 | |||
| f9d58e964c | |||
| d3c14ac303 | |||
| fda9f3beec | |||
| 27365b3c75 | |||
| 3dac332a9f | |||
| c5124b97b6 | |||
| ee2e79db31 | |||
| d05ff678ab | |||
| fcaa9dfb27 | |||
| c30c2feee1 | |||
| 640da34736 | |||
| 93bb8e0eb4 | |||
| 7d9ced8f4f | |||
| b5998ea9d2 | |||
| aabadf1ffd | |||
| e5e5bb3354 | |||
| ea41b0af7a | |||
| 3193ffbd1b | |||
| 5da01cc6df | |||
| 887ea9cf5a | |||
| bee4aa4284 | |||
| b167ed3dd1 | |||
| 6cec024e46 | |||
| 2094886990 | |||
| b0fd38d60c | |||
| 3a680b41b0 | |||
| 02e40bd6f7 | |||
| 19dabf3851 | |||
| eaf3a07579 | |||
| be1da976e1 | |||
| a1ffd5bda6 | |||
| d58a80f3ea | |||
| 45c7afe34c | |||
| e43a8d6ce4 | |||
| e94b93d0c1 | |||
| 2ad26a9156 | |||
| 3383b9c790 | |||
| 6acaf18203 | |||
| 019fbc12b6 | |||
| 5156d635f0 | |||
| f77922522a | |||
| 0db00a8f90 | |||
| fe54640885 | |||
| ec8eaf52bf | |||
| fe5f5ded96 | |||
| 2cb6d731e9 | |||
| 184c52d4ea | |||
| 8e85e244cb | |||
| 43a326a98c | |||
| d9de15a5a0 | |||
| 17ce64037f | |||
| 79793c47bb | |||
| 7091eb9c14 | |||
| dc1e96bbe9 | |||
| 1e37fd9dd2 | |||
| 9aa22af339 | |||
| 26637f0ae5 | |||
| 6d67cad3e7 | |||
| 90d9df8f0e | |||
| ef654a09bb | |||
| 74f3b388d8 | |||
| 5af1f358b2 | |||
| 994325360c | |||
| f0e7f8e3be | |||
| 40d8a6dc8b | |||
| 6488dcfcb2 | |||
| 9ec5f7f59e | |||
| 0621a3febc | |||
| ebd6bee05e | |||
| 119f463c36 | |||
| a3903a252e | |||
| 65ba50c2cf | |||
| cc54a7ad2f | |||
| 7b0403a307 | |||
| 142ff14a50 | |||
| 29a3b395b6 | |||
| 49871f82f3 | |||
| efa3a5d457 | |||
| 0b86f379b4 | |||
| 1cae7130bc | |||
| 45410168af | |||
| 5482e609f8 | |||
| e79f598d17 | |||
| 056070b6a4 | |||
| 8b53c73ce8 | |||
| e6ebf81752 | |||
| 11b8ab1959 | |||
| 17e9259eb1 | |||
| 02c30f8e7e | |||
| 15c7cc2a58 | |||
| f2dbab6171 | |||
| 17af51ce0b | |||
| 8f7fa76e6a | |||
| 4648a82da9 | |||
| 5be82f2d78 | |||
| 9e3aa23f69 | |||
| 46da31d89c | |||
| 5f219c33e6 | |||
| 94fcf20d17 | |||
| 9d39901f78 | |||
| 5cf3f2f76e | |||
| 2f90f9134d | |||
| c57de1081a | |||
| 33d5155a8d | |||
| 9828ff0845 | |||
| da6f746b09 | |||
| 165965d8ea | |||
| e7e01ce9c8 | |||
| c4c49571ec | |||
| 4a63124818 | |||
| d6fa86b870 | |||
| 55d281b0e3 | |||
| a42bc9b281 | |||
| 5bc5567ab1 | |||
| d88ca980ec | |||
| 0ef4f55304 | |||
| 5070bb34e8 | |||
| 02d38d7891 | |||
| c46cc85144 | |||
| 071c23dd52 | |||
| 5f3e426750 | |||
| a9eee19c95 | |||
| 552a8ff8d8 | |||
| bec78beb25 | |||
| a6561a4788 | |||
| c49acc956f | |||
| 4c40b5b370 | |||
| 7c1a142653 | |||
| a161e2f904 | |||
| ad260d9fe0 | |||
| 3fd3bce2cf | |||
| ad6522f547 | |||
| 9ea533a864 | |||
| 61bb8fcafd | |||
| ff5bba3fc5 | |||
| cc1c8bacb4 | |||
| b707b7995b | |||
| a076dfb550 | |||
| 91ac90c2ae | |||
| b12544d3ea | |||
| 38018514dc | |||
| 924d6bc09c | |||
| 1021c2cdcd | |||
| fa01819674 | |||
| eeed2d6369 | |||
| a194be0843 | |||
| aa1cd3829c | |||
| 6c2c25d2cc | |||
| 0b61c7fe48 | |||
| b6745508da | |||
| f5391c3159 | |||
| 9a81932d2b | |||
| 8b50fde9aa | |||
| 8b924df64f | |||
| 7102c50f52 | |||
| e4c65ca220 | |||
| 557dbf3ebe | |||
| 462f7a99b9 | |||
| c68c4e5984 | |||
| f00a051bb3 | |||
| b94e3edf80 | |||
| 51ca3e42b7 | |||
| 943e9a86aa | |||
| c4da591731 | |||
| 287a43ba65 | |||
| 070321230b | |||
| 8886d0dea2 | |||
| d3819c492f | |||
| 3cbb9ef024 | |||
| 41633af763 | |||
| 916ad9d567 | |||
| ccd9ab02c5 | |||
| d0a9cfe6a9 | |||
| 333a3c5901 | |||
| 0de6418bca | |||
| e9b98b837a | |||
| 97be4afbff | |||
| 9ca24efe43 | |||
| 116c8453bb | |||
| 4e7438b9f9 | |||
| c4794db1dd | |||
| 1ffb8a5b85 | |||
| 7ab1a7b88d | |||
| e3f932afe4 | |||
| 5ca2f62854 | |||
| 8afe613451 | |||
| 8de9c6e532 | |||
| b69fcbde11 | |||
| 44d6b6b266 | |||
| 4234769e92 | |||
| c2cabbee09 | |||
| 067b1b6716 | |||
| 5b64046137 | |||
| 085a61721a | |||
| 4c2e11e64b | |||
| 7c5f35b1cf | |||
| 561708b777 | |||
| 806a959662 | |||
| 966a599c2c | |||
| 0ed98c39b6 | |||
| 095bc00b33 | |||
| 43762ce8f4 |
+55
-13
@@ -152,7 +152,7 @@ REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth", "caddy"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
REMNAWAVE_CADDY_TOKEN=YWRtaW46cGFzc3dvcmQ=
|
||||
REMNAWAVE_CADDY_TOKEN=
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
@@ -187,6 +187,42 @@ REMNAWAVE_AUTO_SYNC_ENABLED=false
|
||||
# Времена синхронизации (через запятую, формат HH:MM по МСК)
|
||||
REMNAWAVE_AUTO_SYNC_TIMES=03:00
|
||||
|
||||
# ===== REMNAWAVE WEBHOOKS (входящие события из панели) =====
|
||||
# Включить приём вебхуков от панели Remnawave (real-time события)
|
||||
REMNAWAVE_WEBHOOK_ENABLED=false
|
||||
# Путь для приёма вебхуков (должен совпадать с настройкой в панели)
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
# Общий секрет для подписи HMAC-SHA256 (минимум 32 символа)
|
||||
# Сгенерируйте: openssl rand -hex 32
|
||||
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
|
||||
REMNAWAVE_WEBHOOK_SECRET=
|
||||
|
||||
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
|
||||
# Глобальный переключатель уведомлений пользователям от вебхуков
|
||||
WEBHOOK_NOTIFY_USER_ENABLED=true
|
||||
# Отключение/активация подписки администратором
|
||||
WEBHOOK_NOTIFY_SUB_STATUS=true
|
||||
# Истечение подписки
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRED=true
|
||||
# Предупреждения о скором истечении (72ч, 48ч, 24ч)
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRING=true
|
||||
# Достижение лимита трафика
|
||||
WEBHOOK_NOTIFY_SUB_LIMITED=true
|
||||
# Сброс счётчика трафика
|
||||
WEBHOOK_NOTIFY_TRAFFIC_RESET=true
|
||||
# Удаление пользователя из панели
|
||||
WEBHOOK_NOTIFY_SUB_DELETED=true
|
||||
# Обновление ключей подписки (revoke)
|
||||
WEBHOOK_NOTIFY_SUB_REVOKED=true
|
||||
# Первое подключение к VPN
|
||||
WEBHOOK_NOTIFY_FIRST_CONNECTED=true
|
||||
# Напоминание о неподключении
|
||||
WEBHOOK_NOTIFY_NOT_CONNECTED=true
|
||||
# Предупреждение о приближении к лимиту трафика
|
||||
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD=true
|
||||
# Подключение и отключение устройств
|
||||
WEBHOOK_NOTIFY_DEVICES=true
|
||||
|
||||
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
|
||||
# Тег для пробных пользователей (опционально)
|
||||
# TRIAL_USER_TAG=TRIAL
|
||||
@@ -536,6 +572,8 @@ MULENPAY_MIN_AMOUNT_KOPEKS=10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
|
||||
# Ожидаемый origin для iframe (опционально, для безопасности)
|
||||
# MULENPAY_IFRAME_EXPECTED_ORIGIN=https://mulenpay.ru
|
||||
# URL для редиректа после оплаты (по умолчанию WEBHOOK_URL)
|
||||
# MULENPAY_WEBSITE_URL=https://your-cabinet-url.com
|
||||
|
||||
# PAYPALYCH / PAL24
|
||||
PAL24_ENABLED=false
|
||||
@@ -544,7 +582,6 @@ PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
@@ -660,8 +697,19 @@ CLOUDPAYMENTS_TEST_MODE=false
|
||||
ENABLE_LOGO_MODE=true
|
||||
LOGO_FILE=vpn_logo.png
|
||||
|
||||
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
|
||||
# Режим главного меню:
|
||||
# default - классический режим работы бота (все кнопки внутри Telegram)
|
||||
# cabinet - режим Cabinet с активным ЛК MiniApp, кнопки ведут на конкретные
|
||||
# разделы кабинета (/balance, /subscription, /referral и т.д.)
|
||||
# Требует MINIAPP_CUSTOM_URL
|
||||
# Алиасы для обратной совместимости: text, text_only, minimal
|
||||
MAIN_MENU_MODE=default
|
||||
# Стиль кнопок в режиме Cabinet (Bot API 9.4):
|
||||
# primary - синий
|
||||
# success - зелёный
|
||||
# danger - красный
|
||||
# (пустое) - цвета по умолчанию для каждой секции
|
||||
CABINET_BUTTON_STYLE=
|
||||
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
|
||||
MENU_LAYOUT_ENABLED=false
|
||||
|
||||
@@ -674,7 +722,7 @@ HIDE_SUBSCRIPTION_LINK=false
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - Открывает ссылку напрямую в браузере (режим 4)
|
||||
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
CONNECT_BUTTON_MODE=miniapp_subscription
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
MINIAPP_CUSTOM_URL=
|
||||
@@ -741,7 +789,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
@@ -830,7 +878,7 @@ WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT=0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
BOT_RUN_MODE=polling # polling, webhook или both
|
||||
BOT_RUN_MODE=polling # polling или webhook
|
||||
|
||||
# ===== КОНКУРСНАЯ СИСТЕМА =====
|
||||
CONTESTS_ENABLED=false
|
||||
@@ -838,15 +886,9 @@ CONTESTS_BUTTON_VISIBLE=false
|
||||
# Реферальные конкурсы (турниры среди рефералов)
|
||||
REFERRAL_CONTESTS_ENABLED=false
|
||||
|
||||
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# Автоматическая покупка из сохранённой корзины после пополнения баланса
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
|
||||
# Умная автоактивация: система сама решает — продлить или создать подписку
|
||||
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если true - после пополнения показывает сообщение с кнопками: "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.4.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.4.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v3.4.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v3.4.0-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v3.4.0-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v3.4.0-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
|
||||
@@ -20,6 +20,5 @@ jobs:
|
||||
- uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
release-type: python
|
||||
extra-files: |
|
||||
pyproject.toml
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.4.0"
|
||||
".": "3.12.1"
|
||||
}
|
||||
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
# Changelog
|
||||
|
||||
## [3.12.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.0...v3.12.1) (2026-02-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add /start burst rate-limit to prevent spam abuse ([61a9722](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61a97220d30031816ab23e33a46717e4895c0758))
|
||||
* add promo code anti-abuse protections ([97ec39a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97ec39aa803f0e3f03fdcd482df0cbcb86fd1efd))
|
||||
* handle TelegramBadRequest in ticket edit_message_text calls ([8e61fe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e61fe47746da2ac09c3ea8c4dbfc6be198e49e3))
|
||||
* replace deprecated Query(regex=) with pattern= ([871ceb8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/871ceb866ccf1f3a770c7ef33406e1a43d0a7ff7))
|
||||
|
||||
## [3.12.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.11.0...v3.12.0) (2026-02-15)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
|
||||
* add button style and emoji support for cabinet mode (Bot API 9.4) ([bf2b2f1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406))
|
||||
* add per-button enable/disable toggle and custom labels per locale ([68773b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68773b7e77aa344d18b0f304fa561c91d7631c05))
|
||||
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
|
||||
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
|
||||
* rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections ([ad87c5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad87c5fb5e1a4dd0ef7691f12764d3df1530f643))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
|
||||
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
|
||||
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
|
||||
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
|
||||
* pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults ([46c1a69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46c1a69456036cb1be784b8d952f27110e9124eb))
|
||||
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
|
||||
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
|
||||
|
||||
## [3.11.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.3...v3.11.0) (2026-02-12)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add cabinet admin API for pinned messages management ([1a476c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a476c49c19d1ec2ab2cda1c2ffb5fd242288bb6))
|
||||
* add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL ([476b89f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/476b89fe8e613c505acfc58a9554d31ccf92718a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete ([bfd66c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfd66c42c1fba3763f41d641cea1bd101ec8c10c))
|
||||
* add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode ([1d43ae5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d43ae5e25ffcf0e4fe6fec13319d393717e1e50))
|
||||
* flood control handling in pinned messages and XSS hardening in HTML sanitizer ([454b831](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454b83138e4db8dc4f07171ee6fe262d2cd6d311))
|
||||
* suppress expired callback query error in AuthMiddleware ([2de4384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2de438426a647e2bcae9b4d99eef4093ff8b5429))
|
||||
* ticket creation crash and webhook PendingRollbackError ([760c833](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/760c833b7402541d3c7cf2ed7fc0418119e75042))
|
||||
|
||||
## [3.10.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.2...v3.10.3) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle unique constraint conflicts during backup restore without clear_existing ([5893874](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/589387477624691e0026086800428e7e52e06128))
|
||||
* harden backup create/restore against serialization and constraint errors ([fc42916](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc42916b10bb698895eb75c0e2568747647555d3))
|
||||
* resolve deadlock on server_squads counter updates and add webhook notification toggles ([57dc1ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57dc1ff47f2f6183351db7594544a07ca6f27250))
|
||||
|
||||
## [3.10.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.1...v3.10.2) (2026-02-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
|
||||
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
|
||||
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
|
||||
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
|
||||
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
|
||||
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
|
||||
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
|
||||
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
|
||||
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
|
||||
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
|
||||
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
|
||||
|
||||
## [3.10.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.0...v3.10.1) (2026-02-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
|
||||
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
|
||||
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
|
||||
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
|
||||
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
|
||||
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
|
||||
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
|
||||
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
|
||||
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
|
||||
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
|
||||
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
|
||||
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
|
||||
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
|
||||
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
|
||||
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
|
||||
|
||||
## [3.10.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.1...v3.10.0) (2026-02-10)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add all remaining RemnaWave webhook events (node, service, crm, device) ([1e37fd9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e37fd9dd271814e644af591343cada6ab12d612))
|
||||
* add close button to all webhook notifications ([d9de15a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9de15a5a06aec3901415bdfd25b55d2ca01d28c))
|
||||
* add MULENPAY_WEBSITE_URL setting for post-payment redirect ([fe5f5de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe5f5ded965e36300e1c73f25f16de22f84651ad))
|
||||
* add RemnaWave incoming webhooks for real-time subscription events ([6d67cad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d67cad3e7aa07b8490d88b73c38c4aca6b9e315))
|
||||
* handle errors.bandwidth_usage_threshold_reached_max_notifications webhook ([8e85e24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e85e244cb786fb4c06162f2b98d01202e893315))
|
||||
* handle service.subpage_config_changed webhook event ([43a326a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43a326a98ccc3351de04d9b2d660d3e7e0cb0efc))
|
||||
* unified notification delivery for webhook events (email + WS support) ([26637f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26637f0ae5c7264c0430487d942744fd034e78e8))
|
||||
* webhook protection — prevent sync/monitoring from overwriting webhook data ([184c52d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/184c52d4ea3ce02d40cf8a5ab42be855c7c7ae23))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add action buttons to webhook notifications and fix empty device names ([7091eb9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7091eb9c148aaf913c4699fc86fef5b548002668))
|
||||
* add missing placeholders to Arabic SUBSCRIPTION_INFO template ([fe54640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe546408857128649930de9473c7cde1f7cc450a))
|
||||
* allow non-HTTP deep links in crypto link webhook updates ([f779225](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f77922522a85b3017be44b5fc71da9c95ec16379))
|
||||
* build composite device name from platform + hwid short suffix ([17ce640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ce64037f198837c8f2aa7bf863871f60bdf547))
|
||||
* downgrade transient API errors (502/503/504) to warning level ([ec8eaf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ec8eaf52bfdc2bde612e4fc0324575ba7dc6b2e1))
|
||||
* extract device name from nested hwidUserDevice object ([79793c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79793c47bbbdae8b0f285448d5f70e90c9d4f4b0))
|
||||
* preserve payment initiation time in transaction created_at ([90d9df8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90d9df8f0e949913f09c4ebed8fe5280453ab3ab))
|
||||
* security and architecture fixes for webhook handlers ([dc1e96b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc1e96bbe9b4496e91e9dea591c7fc0ef4cc245b))
|
||||
* stop CryptoBot webhook retry loop and save cabinet payments to DB ([2cb6d73](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cb6d731e96cbfc305b098d8424b84bfd6826fb4))
|
||||
* sync subscription status from panel in user.modified webhook ([5156d63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5156d635f0b5bc0493e8f18ce9710cca6ff4ffc8))
|
||||
* use event field directly as event_name (already includes scope prefix) ([9aa22af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9aa22af3390a249d1b500d75a7d7189daaed265e))
|
||||
* webhook:close button not working due to channel check timeout ([019fbc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/019fbc12b6cf61d374bbed4bce3823afc60445c9))
|
||||
|
||||
## [3.9.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.0...v3.9.1) (2026-02-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
|
||||
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
|
||||
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
|
||||
|
||||
## [3.9.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.8.0...v3.9.0) (2026-02-09)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
|
||||
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
|
||||
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
|
||||
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
|
||||
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
|
||||
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
|
||||
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
|
||||
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
|
||||
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
|
||||
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
|
||||
|
||||
## [3.8.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.2...v3.8.0) (2026-02-08)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add admin device management endpoints ([c57de10](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c57de1081a9e905ba191f64c37221c36713c82a6))
|
||||
* add admin traffic packages and device limit management ([2f90f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f90f9134df58b8c0a329c20060efcf07d5d92f9))
|
||||
* add admin updates endpoint for bot and cabinet releases ([11b8ab1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11b8ab1959e83fafe405be0b76dfa3dd1580a68b))
|
||||
* add endpoint for updating user referral commission percent ([da6f746](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da6f746b093be8cdbf4e2889c50b35087fbc90de))
|
||||
* add enrichment data to CSV export ([f2dbab6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f2dbab617155cdc41573d885f0e55222e5b9825b))
|
||||
* add server-side sorting for enrichment columns ([15c7cc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15c7cc2a58e1f1935d10712a981466629db251d1))
|
||||
* add system info endpoint for admin dashboard ([02c30f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02c30f8e7eb6ba90ed8983cfd82199a22b473bbf))
|
||||
* add traffic usage enrichment endpoint with devices, spending, dates, last node ([5cf3f2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf3f2f76eb2cd93282f845ea0850f6707bfcc09))
|
||||
* admin panel enhancements & bug fixes ([e6ebf81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebf81752499df8eb0a710072785e3d603dba33))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add debug logging for bulk device response structure ([46da31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46da31d89c55c225dec9136d225f2db967cf8961))
|
||||
* add email field to traffic table for OAuth/email users ([94fcf20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94fcf20d17c54efd67fa7bd47eff1afdd1507e08))
|
||||
* add email/UUID fallback for OAuth user panel sync ([165965d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165965d8ea60a002c061fd75f88b759f2da66d7d))
|
||||
* add enrichment device mapping debug logs ([5be82f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5be82f2d78aed9b54d74e86f261baa5655e5dcd9))
|
||||
* include additional devices in tariff renewal price and display ([17e9259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17e9259eb1d41dbf1d313b6a7d500f6458359393))
|
||||
* paginate bulk device endpoint to fetch all HWID devices ([4648a82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4648a82da959410603c92055bcde7f96131e0c29))
|
||||
* read bot version from pyproject.toml when VERSION env is not set ([9828ff0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9828ff0845ec1d199a6fa63fe490ad3570cf9c8f))
|
||||
* revert device pagination, add raw user data field discovery ([8f7fa76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f7fa76e6ab34a3ad2f61f4e1f06026fd3fbf4e3))
|
||||
* use bulk device endpoint instead of per-user calls ([5f219c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f219c33e6d49b0e3e4405a57f8344a4237f1002))
|
||||
* use correct pagination params (start/size) for bulk HWID devices ([17af51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17af51ce0bdfa45197384988d56960a1918ab709))
|
||||
* use per-user panel endpoints for reliable device counts and last node data ([9d39901](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d39901f78ece55c740a5df2603601e5d0b1caca))
|
||||
|
||||
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle FK violation in create_yookassa_payment when user is deleted ([55d281b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/55d281b0e37a6e8977ceff792cccb8669560945b))
|
||||
* remove dots from Remnawave username sanitization ([d6fa86b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d6fa86b870eccbf22327cd205539dd2084f0014e))
|
||||
|
||||
## [3.7.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.0...v3.7.1) (2026-02-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* release-please config — remove blocked workflow files ([d88ca98](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d88ca980ec67e303e37f0094a2912471929b4cef))
|
||||
* remove workflow files and pyproject.toml from release-please extra-files ([5070bb3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5070bb34e8a09b2641783f5e818bb624469ad610))
|
||||
* resolve HWID reset and webhook FK violation ([5f3e426](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f3e426750c2adcb097b92f1a9e7725b1c5c5eba))
|
||||
* resolve HWID reset context manager bug and webhook FK violation ([a9eee19](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9eee19c95efdc38ecf5fa28f7402a2bbba7dd07))
|
||||
* resolve merge conflict in release-please config ([0ef4f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ef4f55304751571754f2027105af3e507f75dfd))
|
||||
* resolve multiple production errors and performance issues ([071c23d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/071c23dd5297c20527442cb5d348d498ebf20af4))
|
||||
|
||||
## [3.7.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.6.0...v3.7.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add admin traffic usage API ([aa1cd38](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa1cd3829c5c3671e220d49dd7ec2d83563e2cf9))
|
||||
* add admin traffic usage API with per-node statistics ([6c2c25d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c2c25d2ccb27446c822e4ed94d9351bfeaf4549))
|
||||
* add node/status filters and custom date range to traffic page ([ad260d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad260d9fe0b232c9d65176502476212902909660))
|
||||
* add node/status filters, custom date range, connected devices to traffic page ([9ea533a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ea533a864e345647754f316bd27971fba1420af))
|
||||
* add node/status filters, date range, devices to traffic page ([ad6522f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad6522f547e68ef5965e70d395ca381b0a032093))
|
||||
* add risk columns to traffic CSV export ([7c1a142](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c1a1426537e43d14eff0a1c3faeca484611b58b))
|
||||
* add tariff filter, fix traffic data aggregation ([fa01819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa01819674b2d2abb0d05b470559b09eb43abef8))
|
||||
* node/status filters + custom date range for traffic page ([a161e2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a161e2f904732b459fef98a67abfaae1214ecfd4))
|
||||
* tariff filter + fix traffic data aggregation ([1021c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1021c2cdcd07cf2194e59af7b59491108339e61f))
|
||||
* traffic filters, date range & risk columns in CSV export ([4c40b5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c40b5b370616a9ab40cbf0cccdbc0ac4a3f8278))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* close unclosed HTML tags in version notification ([0b61c7f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b61c7fe482e7bbfbb3421307a96d54addfd91ee))
|
||||
* close unclosed HTML tags when truncating version notification ([b674550](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6745508da861af9b2ff05d89b4ac9a3933da510))
|
||||
* correct response parsing for non-legacy node-users endpoint ([a076dfb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a076dfb5503a349450b5aa8aac3c6f40070b715d))
|
||||
* correct response parsing for non-legacy node-users endpoint ([91ac90c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91ac90c2aecfb990679b3d0c835314dde448886a))
|
||||
* handle mixed types in traffic sort ([eeed2d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eeed2d6369b07860505c59bcff391e7b17e0ffb7))
|
||||
* handle mixed types in traffic sort for string fields ([a194be0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a194be0843856b3376167d9ba8a8ef737280998c))
|
||||
* resolve 429 rate limiting on traffic page ([b12544d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b12544d3ea8f4bbd2d8c941f83ee3ac412157adb))
|
||||
* resolve 429 rate limiting on traffic page ([924d6bc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/924d6bc09c815c1d188ea1d0e7974f7e803c1d3f))
|
||||
* use legacy per-node endpoint for traffic aggregation ([cc1c8ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc1c8bacb42a9089021b7ae0fecd1f2717953efb))
|
||||
* use legacy per-node endpoint with correct response format ([b707b79](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b707b7995b90c6465910a35e9a4403e1408c6568))
|
||||
* use PaymentService for cabinet YooKassa payments ([61bb8fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61bb8fcafd94509568f134ccdba7769b66cc7d5d))
|
||||
* use PaymentService for cabinet YooKassa payments to save local DB record ([ff5bba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff5bba3fc5d1e1b08d008b64215e487a9eb70960))
|
||||
|
||||
## [3.6.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.5.0...v3.6.0) (2026-02-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([97be4af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97be4afbffd809fe2786a6d248fc4d3f770cb8cf))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([287a43b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287a43ba6527ff3464a527821d746a68e5371bbe))
|
||||
* add panel info, node usage endpoints and campaign to user detail ([0703212](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/070321230bcb868e4bc7a39c287ed3431a4aef4a))
|
||||
* add TRIAL_DISABLED_FOR setting to disable trial by user type ([c4794db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4794db1dd78f7c48b5da896bdb2f000e493e079))
|
||||
* add user_id filter to admin tickets endpoint ([8886d0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8886d0dea20aa5a31c6b6f0c3391b3c012b4b34d))
|
||||
* add user_id filter to admin tickets endpoint ([d3819c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3819c492f88794e4466c2da986fd3a928d7f3df))
|
||||
* block registration with disposable email addresses ([9ca24ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca24efe434278925c0c1f8d2f2d644a67985c89))
|
||||
* block registration with disposable email addresses ([116c845](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/116c8453bb371b5eacf5c9d07f497eb449a355cc))
|
||||
* disable trial by user type (email/telegram/all) ([4e7438b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e7438b9f9c01e30c48fcf2bbe191e9b11598185))
|
||||
* migrate OAuth state storage from in-memory to Redis ([e9b98b8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b98b837a8552360ef4c41f6cd7a5779aa8b0a7))
|
||||
* OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([3cbb9ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3cbb9ef024695352959ef9a82bf8b81f0ba1d940))
|
||||
* return 30-day daily breakdown for node usage ([7102c50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7102c50f52d583add863331e96f3a9de189f581a))
|
||||
* return 30-day daily breakdown for node usage ([e4c65ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4c65ca220994cf08ed3510f51d9e2808bb2d154))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* increase OAuth HTTP timeout to 30s ([333a3c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/333a3c590120a64f6b2963efab1edd861274840c))
|
||||
* parse bandwidth stats series format for node usage ([557dbf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/557dbf3ebe777d2137e0e28303dc2a803b15c1c6))
|
||||
* parse bandwidth stats series format for node usage ([462f7a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/462f7a99b9d5c0b7436dbc3d6ab5db6c6cfa3118))
|
||||
* pass tariff object instead of tariff_id to set_tariff_promo_groups ([1ffb8a5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ffb8a5b85455396006e1fcddd48f4c9a2ca2700))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([b94e3ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b94e3edf80e747077992c03882119c7559ad1c31))
|
||||
* query per-node legacy endpoint for user traffic breakdown ([51ca3e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51ca3e42b75c1870c76a1b25f667629855cfe886))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([c68c4e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c68c4e59846abba9c7c78ae91ec18e2e0e329e3c))
|
||||
* reduce node usage to 2 API calls to avoid 429 rate limit ([f00a051](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f00a051bb323e5ba94a3c38939870986726ed58e))
|
||||
* use accessible nodes API and fix date format for node usage ([943e9a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/943e9a86aaa449cd3154b0919cfdc52d2a35b509))
|
||||
* use accessible nodes API and fix date format for node usage ([c4da591](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4da59173155e2eeb69eca21416f816fcbd1fa9c))
|
||||
|
||||
## [3.5.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.4.0...v3.5.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add tariff reorder API endpoint ([4c2e11e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2e11e64bed41592f5a12061dcca74ce43e0806))
|
||||
* pass platform-level fields from RemnaWave config to frontend ([095bc00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/095bc00b33d7082558a8b7252906db2850dce9da))
|
||||
* serve original RemnaWave config from app-config endpoint ([43762ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43762ce8f4fa7142a1ca62a92b97a027dab2564d))
|
||||
* tariff reorder API endpoint ([085a617](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/085a61721a8175b3f4fd744614c446d73346f2b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enforce blacklist via middleware ([561708b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/561708b7772ec5b84d6ee049aeba26dc70675583))
|
||||
* enforce blacklist via middleware instead of per-handler checks ([966a599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/966a599c2c778dce9eea3c61adf6067fb33119f6))
|
||||
* exclude signature field from Telegram initData HMAC validation ([5b64046](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b6404613772610c595e55bde1249cdf6ec3269d))
|
||||
* improve button URL resolution and pass uiConfig to frontend ([0ed98c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed98c39b6c95911a38a26a32d0ffbcf9cfd7c80))
|
||||
* restore unquote for user data parsing in telegram auth ([c2cabbe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c2cabbee097a41a95d16c34d43ab7e70d076c4dc))
|
||||
|
||||
|
||||
### Reverts
|
||||
|
||||
* remove signature pop from HMAC validation ([4234769](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4234769e92104a6c4f8f1d522e1fca25bc7b20d0))
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.4.0"
|
||||
ARG VERSION="v3.12.1" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@ docker compose logs
|
||||
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
||||
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
||||
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
|
||||
|
||||
### 2. Минимальные настройки для webhook
|
||||
|
||||
@@ -611,6 +610,16 @@ hooks.domain.com {
|
||||
}
|
||||
}
|
||||
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
handle /app-config.json {
|
||||
header Access-Control-Allow-Origin "*"
|
||||
@@ -819,6 +828,18 @@ http {
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# app-config.json с CORS
|
||||
location = /app-config.json {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
@@ -1012,7 +1033,7 @@ curl -I https://miniapp.domain.com
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ |
|
||||
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling` или `webhook`. |
|
||||
|
||||
[Полный список доступных параметров:](.env.example)
|
||||
|
||||
@@ -1022,7 +1043,7 @@ curl -I https://miniapp.domain.com
|
||||
|
||||
### 🤖 Режимы запуска бота
|
||||
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling` или `webhook`.
|
||||
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
|
||||
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
|
||||
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
|
||||
@@ -1056,6 +1077,102 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
|
||||
REMNAWAVE_SECRET_KEY=secret_key_name
|
||||
```
|
||||
|
||||
### 📡 Вебхуки Remnawave (real-time события)
|
||||
|
||||
Бот может принимать входящие вебхуки от панели Remnawave для мгновенной реакции на события подписок. Это значительно улучшает скорость обновления данных по сравнению с периодической синхронизацией.
|
||||
|
||||
#### Поддерживаемые события
|
||||
|
||||
| Событие | Описание |
|
||||
|---------|----------|
|
||||
| `user.expired` | Подписка истекла |
|
||||
| `user.disabled` | Подписка деактивирована |
|
||||
| `user.enabled` | Подписка активирована |
|
||||
| `user.limited` | Превышен лимит трафика |
|
||||
| `user.traffic_reset` | Трафик сброшен |
|
||||
| `user.modified` | Данные подписки изменены (трафик, дата, URL) |
|
||||
| `user.deleted` | Пользователь удалён |
|
||||
| `user.revoked` | Ключи подписки отозваны |
|
||||
| `user.created` | Пользователь создан |
|
||||
| `user.expires_in_*` | Предупреждения об истечении (72ч, 48ч, 24ч) |
|
||||
| `user.first_connected` | Первое подключение |
|
||||
| `user.bandwidth_usage_threshold_reached` | Порог трафика достигнут |
|
||||
| `user_hwid_devices.*` | Устройство добавлено/удалено |
|
||||
| `node.*`, `service.*` | Административные события (ноды, сервис) |
|
||||
|
||||
#### Настройка
|
||||
|
||||
**1. Переменные окружения в `.env`:**
|
||||
|
||||
```env
|
||||
REMNAWAVE_WEBHOOK_ENABLED=true
|
||||
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
|
||||
REMNAWAVE_WEBHOOK_SECRET=your_secret_min_32_chars_here
|
||||
```
|
||||
|
||||
Сгенерируйте секрет:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
**2. Настройка в панели Remnawave:**
|
||||
|
||||
В env панели Remnawave заполните:
|
||||
|
||||
- **URL**: `https://hooks.domain.com/remnawave-webhook`
|
||||
- **Secret**: тот же секрет, что и в `REMNAWAVE_WEBHOOK_SECRET`
|
||||
|
||||
**3. Настройка прокси:**
|
||||
|
||||
Добавьте путь `/remnawave-webhook` в конфигурацию обратного прокси.
|
||||
|
||||
**Caddy:**
|
||||
|
||||
```caddy
|
||||
handle /remnawave-webhook {
|
||||
reverse_proxy remnawave_bot:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
transport http {
|
||||
read_buffer 0
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nginx:**
|
||||
|
||||
```nginx
|
||||
location = /remnawave-webhook {
|
||||
proxy_pass http://remnawave_bot_unified;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
```
|
||||
|
||||
**4. Проверка работоспособности:**
|
||||
|
||||
```bash
|
||||
# Health-check (GET запрос)
|
||||
curl -s https://hooks.domain.com/remnawave-webhook | jq
|
||||
|
||||
# Ожидаемый ответ:
|
||||
# {"status": "ok", "service": "remnawave_webhook", "enabled": true}
|
||||
```
|
||||
|
||||
**Важно:**
|
||||
- Секрет должен быть не менее 32 символов
|
||||
- Бот верифицирует подпись `X-Remnawave-Signature` (HMAC-SHA256) для каждого запроса
|
||||
- При включённых вебхуках бот автоматически защищает подписки от перезаписи данными из периодической синхронизации в течение 60 секунд после получения события
|
||||
- Если бот и панель на одном сервере, URL вебхука может быть `http://remnawave_bot:8080/remnawave-webhook` (внутри Docker-сети)
|
||||
|
||||
### 💳 Freekassa
|
||||
|
||||
Платёжный провайдер [Freekassa](https://freekassa.ru) поддерживает NSPK СБП и банковские карты.
|
||||
@@ -1343,7 +1460,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 Автоплатёж с настройкой дня списания
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- ⚡ **Быстрое пополнение** с кнопками быстрых сумм
|
||||
- 🔄 **Умная автоактивация** подписки после пополнения баланса
|
||||
|
||||
📱 **Управление подписками**
|
||||
|
||||
@@ -1530,7 +1646,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
||||
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
||||
- 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
|
||||
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
|
||||
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
|
||||
|
||||
|
||||
+56
-16
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
from aiogram import Bot, Dispatcher, types
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
@@ -61,8 +60,9 @@ from app.handlers.admin import (
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
from app.middlewares.auth import AuthMiddleware
|
||||
from app.middlewares.blacklist import BlacklistMiddleware
|
||||
from app.middlewares.button_stats import ButtonStatsMiddleware
|
||||
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
|
||||
from app.middlewares.context_binding import ContextVarsMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
@@ -75,14 +75,14 @@ from app.utils.message_patch import patch_message_methods
|
||||
|
||||
patch_message_methods()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def debug_callback_handler(callback: types.CallbackQuery):
|
||||
logger.info('🔍 DEBUG CALLBACK:')
|
||||
logger.info(f' - Data: {callback.data}')
|
||||
logger.info(f' - User: {callback.from_user.id}')
|
||||
logger.info(f' - Username: {callback.from_user.username}')
|
||||
logger.info('Data', callback_data=callback.data)
|
||||
logger.info('User', from_user_id=callback.from_user.id)
|
||||
logger.info('Username', username=callback.from_user.username)
|
||||
|
||||
|
||||
async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
@@ -90,7 +90,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
await cache.connect()
|
||||
logger.info('Кеш инициализирован')
|
||||
except Exception as e:
|
||||
logger.warning(f'Кеш не инициализирован: {e}')
|
||||
logger.warning('Кеш не инициализирован', error=e)
|
||||
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -106,12 +106,15 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
storage = RedisStorage(redis_client)
|
||||
logger.info('Подключено к Redis для FSM storage')
|
||||
except Exception as e:
|
||||
logger.warning(f'Не удалось подключиться к Redis: {e}')
|
||||
logger.warning('Не удалось подключиться к Redis', error=e)
|
||||
logger.info('Используется MemoryStorage для FSM')
|
||||
storage = MemoryStorage()
|
||||
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
dp.message.middleware(ContextVarsMiddleware())
|
||||
dp.callback_query.middleware(ContextVarsMiddleware())
|
||||
dp.pre_checkout_query.middleware(ContextVarsMiddleware())
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
@@ -119,10 +122,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
dp.message.middleware(MaintenanceMiddleware())
|
||||
dp.callback_query.middleware(MaintenanceMiddleware())
|
||||
display_name_middleware = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_middleware)
|
||||
dp.callback_query.middleware(display_name_middleware)
|
||||
dp.pre_checkout_query.middleware(display_name_middleware)
|
||||
blacklist_middleware = BlacklistMiddleware()
|
||||
dp.message.middleware(blacklist_middleware)
|
||||
dp.callback_query.middleware(blacklist_middleware)
|
||||
dp.pre_checkout_query.middleware(blacklist_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
@@ -205,11 +208,48 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
await maintenance_service.start_monitoring()
|
||||
logger.info('Мониторинг техработ запущен')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка запуска мониторинга техработ: {e}')
|
||||
logger.error('Ошибка запуска мониторинга техработ', error=e)
|
||||
else:
|
||||
logger.info('Мониторинг техработ отключен настройками')
|
||||
|
||||
logger.info('🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries')
|
||||
|
||||
# Validate CONNECT_BUTTON_MODE dependencies
|
||||
if not settings.get_happ_cryptolink_redirect_template():
|
||||
if settings.CONNECT_BUTTON_MODE == 'happ_cryptolink':
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=happ_cryptolink, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
|
||||
'Кнопка "Подключиться" не будет отображаться.'
|
||||
)
|
||||
elif settings.CONNECT_BUTTON_MODE == 'guide':
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=guide, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
|
||||
'Кнопка "Подключиться" в гайдах не будет работать — Telegram не поддерживает '
|
||||
'кастомные схемы (happ://, v2ray://) в inline-кнопках без HTTPS-редиректа.'
|
||||
)
|
||||
if settings.CONNECT_BUTTON_MODE == 'miniapp_custom' and not settings.MINIAPP_CUSTOM_URL:
|
||||
logger.warning(
|
||||
'⚠️ CONNECT_BUTTON_MODE=miniapp_custom, но MINIAPP_CUSTOM_URL не задан! '
|
||||
'Кнопка "Подключиться" не будет работать.'
|
||||
)
|
||||
if settings.is_cabinet_mode() and not settings.MINIAPP_CUSTOM_URL:
|
||||
logger.warning(
|
||||
'⚠️ MAIN_MENU_MODE=cabinet, но MINIAPP_CUSTOM_URL не задан! '
|
||||
'Кнопки кабинета не смогут открывать разделы MiniApp. '
|
||||
'Установите MINIAPP_CUSTOM_URL.'
|
||||
)
|
||||
elif settings.is_cabinet_mode():
|
||||
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
|
||||
|
||||
# Load per-section button styles cache
|
||||
if settings.is_cabinet_mode():
|
||||
try:
|
||||
from app.utils.button_styles_cache import load_button_styles_cache
|
||||
|
||||
await load_button_styles_cache()
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load button styles cache', error=e)
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
return bot, dp
|
||||
@@ -220,10 +260,10 @@ async def shutdown_bot():
|
||||
await maintenance_service.stop_monitoring()
|
||||
logger.info('Мониторинг техработ остановлен')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка остановки мониторинга: {e}')
|
||||
logger.error('Ошибка остановки мониторинга', error=e)
|
||||
|
||||
try:
|
||||
await cache.close()
|
||||
logger.info('Соединения с кешем закрыты')
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка закрытия кеша: {e}')
|
||||
logger.error('Ошибка закрытия кеша', error=e)
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
"""OAuth 2.0 provider implementations for cabinet authentication."""
|
||||
|
||||
import secrets
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.utils.cache import cache, cache_key
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
STATE_TTL_SECONDS = 600 # 10 minutes
|
||||
|
||||
|
||||
# --- Typed dicts for provider API responses ---
|
||||
|
||||
|
||||
class OAuthProviderConfig(TypedDict):
|
||||
client_id: str
|
||||
client_secret: str
|
||||
enabled: bool
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthTokenResponse(TypedDict, total=False):
|
||||
access_token: str
|
||||
token_type: str
|
||||
expires_in: int
|
||||
refresh_token: str
|
||||
scope: str
|
||||
# VK-specific: email and user_id come in token response
|
||||
email: str
|
||||
user_id: int
|
||||
|
||||
|
||||
class GoogleUserInfoResponse(TypedDict, total=False):
|
||||
sub: str
|
||||
email: str
|
||||
email_verified: bool
|
||||
given_name: str
|
||||
family_name: str
|
||||
picture: str
|
||||
name: str
|
||||
|
||||
|
||||
class YandexUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
login: str
|
||||
default_email: str
|
||||
emails: list[str]
|
||||
first_name: str
|
||||
last_name: str
|
||||
default_avatar_id: str
|
||||
|
||||
|
||||
class DiscordUserInfoResponse(TypedDict, total=False):
|
||||
id: str
|
||||
username: str
|
||||
global_name: str
|
||||
email: str
|
||||
verified: bool
|
||||
avatar: str
|
||||
|
||||
|
||||
class VKUserInfoItem(TypedDict, total=False):
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
photo_200: str
|
||||
|
||||
|
||||
class VKUserInfoResponse(TypedDict, total=False):
|
||||
response: list[VKUserInfoItem]
|
||||
|
||||
|
||||
# --- Models ---
|
||||
|
||||
|
||||
class OAuthUserInfo(BaseModel):
|
||||
"""Normalized user info from OAuth provider."""
|
||||
|
||||
provider: str
|
||||
provider_id: str
|
||||
email: str | None = None
|
||||
email_verified: bool = False
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
username: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
# --- CSRF state management (Redis) ---
|
||||
|
||||
|
||||
async def generate_oauth_state(provider: str) -> str:
|
||||
"""Generate a CSRF state token for OAuth flow. Stored in Redis with TTL."""
|
||||
state = secrets.token_urlsafe(32)
|
||||
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
|
||||
return state
|
||||
|
||||
|
||||
async def validate_oauth_state(state: str, provider: str) -> bool:
|
||||
"""Validate and consume a CSRF state token from Redis."""
|
||||
key = cache_key('oauth_state', state)
|
||||
stored_provider: str | None = await cache.get(key)
|
||||
if stored_provider is None:
|
||||
return False
|
||||
await cache.delete(key)
|
||||
if stored_provider != provider:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# --- Provider implementations ---
|
||||
|
||||
|
||||
class OAuthProvider(ABC):
|
||||
"""Base class for OAuth 2.0 providers."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
def __init__(self, client_id: str, client_secret: str, redirect_uri: str) -> None:
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
@abstractmethod
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
"""Build the authorization URL for the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
"""Exchange authorization code for tokens."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
"""Fetch user info from the provider."""
|
||||
|
||||
|
||||
class GoogleProvider(OAuthProvider):
|
||||
name = 'google'
|
||||
display_name = 'Google'
|
||||
|
||||
AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
|
||||
TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
||||
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'openid email profile',
|
||||
'state': state,
|
||||
'access_type': 'offline',
|
||||
'prompt': 'select_account',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
json={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: GoogleUserInfoResponse = response.json()
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='google',
|
||||
provider_id=str(data['sub']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('email_verified', False),
|
||||
first_name=data.get('given_name'),
|
||||
last_name=data.get('family_name'),
|
||||
avatar_url=data.get('picture'),
|
||||
)
|
||||
|
||||
|
||||
class YandexProvider(OAuthProvider):
|
||||
name = 'yandex'
|
||||
display_name = 'Yandex'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.yandex.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.yandex.com/token'
|
||||
USERINFO_URL = 'https://login.yandex.ru/info'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'login:info login:email',
|
||||
'state': state,
|
||||
'force_confirm': 'yes',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={'format': 'json'},
|
||||
headers={'Authorization': f'OAuth {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: YandexUserInfoResponse = response.json()
|
||||
|
||||
default_email = data.get('default_email')
|
||||
emails = data.get('emails', [])
|
||||
email = default_email or (emails[0] if emails else None)
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='yandex',
|
||||
provider_id=str(data['id']),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=data.get('first_name'),
|
||||
last_name=data.get('last_name'),
|
||||
username=data.get('login'),
|
||||
avatar_url=(
|
||||
f'https://avatars.yandex.net/get-yapic/{data["default_avatar_id"]}/islands-200'
|
||||
if data.get('default_avatar_id')
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DiscordProvider(OAuthProvider):
|
||||
name = 'discord'
|
||||
display_name = 'Discord'
|
||||
|
||||
AUTHORIZE_URL = 'https://discord.com/api/oauth2/authorize'
|
||||
TOKEN_URL = 'https://discord.com/api/oauth2/token'
|
||||
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'identify email',
|
||||
'state': state,
|
||||
'prompt': 'consent',
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
headers={'Authorization': f'Bearer {access_token}'},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: DiscordUserInfoResponse = response.json()
|
||||
|
||||
avatar_url: str | None = None
|
||||
if data.get('avatar'):
|
||||
avatar_url = f'https://cdn.discordapp.com/avatars/{data["id"]}/{data["avatar"]}.png'
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='discord',
|
||||
provider_id=str(data['id']),
|
||||
email=data.get('email'),
|
||||
email_verified=data.get('verified', False),
|
||||
first_name=data.get('global_name') or data.get('username'),
|
||||
username=data.get('username'),
|
||||
avatar_url=avatar_url,
|
||||
)
|
||||
|
||||
|
||||
class VKProvider(OAuthProvider):
|
||||
name = 'vk'
|
||||
display_name = 'VK'
|
||||
|
||||
AUTHORIZE_URL = 'https://oauth.vk.com/authorize'
|
||||
TOKEN_URL = 'https://oauth.vk.com/access_token'
|
||||
USERINFO_URL = 'https://api.vk.com/method/users.get'
|
||||
API_VERSION = '5.131'
|
||||
|
||||
def get_authorization_url(self, state: str) -> str:
|
||||
params: dict[str, str] = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': 'email',
|
||||
'state': state,
|
||||
'v': self.API_VERSION,
|
||||
}
|
||||
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
|
||||
return str(request.url)
|
||||
|
||||
async def exchange_code(self, code: str) -> OAuthTokenResponse:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.TOKEN_URL,
|
||||
params={
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'code': code,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: OAuthTokenResponse = response.json()
|
||||
return data
|
||||
|
||||
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
|
||||
access_token = token_data['access_token']
|
||||
user_id: int | None = token_data.get('user_id')
|
||||
# VK returns email in token response, not in userinfo
|
||||
email: str | None = token_data.get('email')
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
self.USERINFO_URL,
|
||||
params={
|
||||
'access_token': access_token,
|
||||
'fields': 'photo_200',
|
||||
'v': self.API_VERSION,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data: VKUserInfoResponse = response.json()
|
||||
|
||||
users: list[Any] = data.get('response', [])
|
||||
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
|
||||
|
||||
return OAuthUserInfo(
|
||||
provider='vk',
|
||||
provider_id=str(user_id or user_data.get('id', '')),
|
||||
email=email,
|
||||
email_verified=bool(email),
|
||||
first_name=user_data.get('first_name'),
|
||||
last_name=user_data.get('last_name'),
|
||||
avatar_url=user_data.get('photo_200'),
|
||||
)
|
||||
|
||||
|
||||
# --- Provider factory ---
|
||||
|
||||
_PROVIDERS: dict[str, type[OAuthProvider]] = {
|
||||
'google': GoogleProvider,
|
||||
'yandex': YandexProvider,
|
||||
'discord': DiscordProvider,
|
||||
'vk': VKProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> OAuthProvider | None:
|
||||
"""Get an OAuth provider instance if enabled.
|
||||
|
||||
Returns None if the provider is not enabled or not found.
|
||||
"""
|
||||
providers_config: dict[str, OAuthProviderConfig] = settings.get_oauth_providers_config()
|
||||
config = providers_config.get(name)
|
||||
if not config or not config['enabled']:
|
||||
return None
|
||||
|
||||
provider_class = _PROVIDERS.get(name)
|
||||
if not provider_class:
|
||||
return None
|
||||
|
||||
redirect_uri = f'{settings.CABINET_URL}/auth/oauth/callback'
|
||||
|
||||
return provider_class(
|
||||
client_id=config['client_id'],
|
||||
client_secret=config['client_secret'],
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""FastAPI dependencies for cabinet module."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
@@ -12,12 +12,13 @@ from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import User
|
||||
from app.services.blacklist_service import blacklist_service
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
|
||||
from .auth.jwt_handler import get_token_payload
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -104,6 +105,18 @@ async def get_current_cabinet_user(
|
||||
detail='User account is not active',
|
||||
)
|
||||
|
||||
# Check blacklist
|
||||
if user.telegram_id is not None:
|
||||
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
|
||||
if is_blacklisted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'blacklisted',
|
||||
'message': reason or 'Доступ запрещен',
|
||||
},
|
||||
)
|
||||
|
||||
# Check maintenance mode (allow admins to pass)
|
||||
if maintenance_service.is_maintenance_active():
|
||||
# Проверяем админа по telegram_id ИЛИ email
|
||||
@@ -148,10 +161,12 @@ async def get_current_cabinet_user(
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
logger.warning(f'Timeout checking channel subscription for user {user.telegram_id}')
|
||||
logger.warning('Timeout checking channel subscription for user', telegram_id=user.telegram_id)
|
||||
# Don't block user if check times out
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to check channel subscription for user {user.telegram_id}: {e}')
|
||||
logger.warning(
|
||||
'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e
|
||||
)
|
||||
# Don't block user if check fails
|
||||
|
||||
return user
|
||||
|
||||
@@ -5,10 +5,12 @@ from fastapi import APIRouter
|
||||
from .admin_apps import router as admin_apps_router
|
||||
from .admin_ban_system import router as admin_ban_system_router
|
||||
from .admin_broadcasts import router as admin_broadcasts_router
|
||||
from .admin_button_styles import router as admin_button_styles_router
|
||||
from .admin_campaigns import router as admin_campaigns_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_payment_methods import router as admin_payment_methods_router
|
||||
from .admin_payments import router as admin_payments_router
|
||||
from .admin_pinned_messages import router as admin_pinned_messages_router
|
||||
from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
@@ -17,6 +19,8 @@ from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
from .admin_tariffs import router as admin_tariffs_router
|
||||
from .admin_tickets import router as admin_tickets_router
|
||||
from .admin_traffic import router as admin_traffic_router
|
||||
from .admin_updates import router as admin_updates_router
|
||||
from .admin_users import router as admin_users_router
|
||||
from .admin_wheel import router as admin_wheel_router
|
||||
from .auth import router as auth_router
|
||||
@@ -26,6 +30,7 @@ from .contests import router as contests_router
|
||||
from .info import router as info_router
|
||||
from .media import router as media_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
from .polls import router as polls_router
|
||||
from .promo import router as promo_router
|
||||
from .promocode import router as promocode_router
|
||||
@@ -45,6 +50,7 @@ router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
|
||||
|
||||
# Include all sub-routers
|
||||
router.include_router(auth_router)
|
||||
router.include_router(oauth_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
@@ -83,6 +89,10 @@ router.include_router(admin_payments_router)
|
||||
router.include_router(admin_promo_offers_router)
|
||||
router.include_router(admin_remnawave_router)
|
||||
router.include_router(admin_email_templates_router)
|
||||
router.include_router(admin_updates_router)
|
||||
router.include_router(admin_traffic_router)
|
||||
router.include_router(admin_pinned_messages_router)
|
||||
router.include_router(admin_button_styles_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin routes for managing VPN applications in app-config.json."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,7 +16,7 @@ from app.services.system_settings_service import bot_configuration_service
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
|
||||
|
||||
@@ -231,7 +231,7 @@ async def create_app(
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
|
||||
logger.info('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, platform=platform)
|
||||
|
||||
return request.app
|
||||
|
||||
@@ -274,7 +274,7 @@ async def update_app(
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
|
||||
logger.info('Admin updated app in platform', admin_id=admin.id, app_id=app_id, platform=platform)
|
||||
|
||||
return request.app
|
||||
|
||||
@@ -310,7 +310,7 @@ async def delete_app(
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
|
||||
logger.info('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, platform=platform)
|
||||
|
||||
return {'status': 'deleted', 'app_id': app_id}
|
||||
|
||||
@@ -355,7 +355,7 @@ async def reorder_apps(
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
|
||||
logger.info('Admin reordered apps in platform', admin_id=admin.id, platform=platform)
|
||||
|
||||
return {'status': 'reordered', 'order': request.app_ids}
|
||||
|
||||
@@ -374,7 +374,7 @@ async def update_branding(
|
||||
config['config']['branding'] = request.branding.model_dump()
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f'Admin {admin.id} updated branding')
|
||||
logger.info('Admin updated branding', admin_id=admin.id)
|
||||
|
||||
return request.branding
|
||||
|
||||
@@ -434,7 +434,14 @@ async def copy_app_to_platform(
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
|
||||
logger.info(
|
||||
'Admin copied app from to as',
|
||||
admin_id=admin.id,
|
||||
app_id=app_id,
|
||||
platform=platform,
|
||||
target_platform=target_platform,
|
||||
new_id=new_id,
|
||||
)
|
||||
|
||||
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
|
||||
|
||||
@@ -498,9 +505,9 @@ async def set_remnawave_config_uuid(
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
|
||||
await db.commit()
|
||||
logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'")
|
||||
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=uuid_value)
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving RemnaWave config UUID: {e}')
|
||||
logger.error('Error saving RemnaWave config UUID', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to save configuration',
|
||||
@@ -547,7 +554,7 @@ async def get_remnawave_subscription_config(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching RemnaWave config: {e}')
|
||||
logger.error('Error fetching RemnaWave config', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to fetch config from RemnaWave: {e!s}',
|
||||
@@ -572,7 +579,7 @@ async def list_remnawave_subscription_configs(
|
||||
for c in configs
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f'Error listing RemnaWave configs: {e}')
|
||||
logger.error('Error listing RemnaWave configs', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to fetch configs from RemnaWave: {e!s}',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Admin routes for Ban System monitoring in cabinet."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.config import settings
|
||||
@@ -45,7 +45,7 @@ from ..schemas.ban_system import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/ban-system', tags=['Cabinet Admin Ban System'])
|
||||
|
||||
@@ -53,9 +53,11 @@ router = APIRouter(prefix='/admin/ban-system', tags=['Cabinet Admin Ban System']
|
||||
def _get_ban_api() -> BanSystemAPI:
|
||||
"""Get Ban System API instance."""
|
||||
logger.debug(
|
||||
f'Ban System check - enabled: {settings.is_ban_system_enabled()}, configured: {settings.is_ban_system_configured()}'
|
||||
'Ban System check enabled: configured',
|
||||
is_ban_system_enabled=settings.is_ban_system_enabled(),
|
||||
is_ban_system_configured=settings.is_ban_system_configured(),
|
||||
)
|
||||
logger.debug(f'Ban System URL: {settings.get_ban_system_api_url()}')
|
||||
logger.debug('Ban System URL', get_ban_system_api_url=settings.get_ban_system_api_url())
|
||||
|
||||
if not settings.is_ban_system_enabled():
|
||||
raise HTTPException(
|
||||
@@ -83,13 +85,13 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any:
|
||||
func = getattr(api, method)
|
||||
return await func(*args, **kwargs)
|
||||
except BanSystemAPIError as e:
|
||||
logger.error(f'Ban System API error: {e}')
|
||||
logger.error('Ban System API error', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f'Ban System API error: {e.message}',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Ban System unexpected error: {e}')
|
||||
logger.error('Ban System unexpected error', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Internal error: {e!s}',
|
||||
@@ -133,7 +135,7 @@ async def get_stats(
|
||||
api = _get_ban_api()
|
||||
data = await _api_request(api, 'get_stats')
|
||||
|
||||
logger.debug(f'Ban System raw stats: {data}')
|
||||
logger.debug('Ban System raw stats', data=data)
|
||||
|
||||
# Extract punishment stats
|
||||
punishment_stats = data.get('punishment_stats') or {}
|
||||
@@ -364,7 +366,7 @@ async def unban_user(
|
||||
api = _get_ban_api()
|
||||
try:
|
||||
await _api_request(api, 'enable_user', user_id=user_id)
|
||||
logger.info(f'Admin {admin.id} unbanned user {user_id} in Ban System')
|
||||
logger.info('Admin unbanned user in Ban System', admin_id=admin.id, user_id=user_id)
|
||||
return UnbanResponse(success=True, message='User unbanned successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -387,7 +389,7 @@ async def ban_user(
|
||||
minutes=request.minutes,
|
||||
reason=request.reason,
|
||||
)
|
||||
logger.info(f'Admin {admin.id} banned user {request.username}: {request.reason}')
|
||||
logger.info('Admin banned user', admin_id=admin.id, username=request.username, reason=request.reason)
|
||||
return UnbanResponse(success=True, message='User banned successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -819,7 +821,7 @@ async def set_setting(
|
||||
api = _get_ban_api()
|
||||
data = await _api_request(api, 'set_setting', key=key, value=value)
|
||||
|
||||
logger.info(f'Admin {admin.id} changed Ban System setting {key} to {value}')
|
||||
logger.info('Admin changed Ban System setting to', admin_id=admin.id, key=key, value=value)
|
||||
|
||||
return _parse_setting_response(key, data)
|
||||
|
||||
@@ -833,7 +835,7 @@ async def toggle_setting(
|
||||
api = _get_ban_api()
|
||||
data = await _api_request(api, 'toggle_setting', key=key)
|
||||
|
||||
logger.info(f'Admin {admin.id} toggled Ban System setting {key}')
|
||||
logger.info('Admin toggled Ban System setting', admin_id=admin.id, key=key)
|
||||
|
||||
return _parse_setting_response(key, data, default_type='bool')
|
||||
|
||||
@@ -850,7 +852,7 @@ async def whitelist_add(
|
||||
api = _get_ban_api()
|
||||
try:
|
||||
await _api_request(api, 'whitelist_add', username=request.username)
|
||||
logger.info(f'Admin {admin.id} added {request.username} to Ban System whitelist')
|
||||
logger.info('Admin added to Ban System whitelist', admin_id=admin.id, username=request.username)
|
||||
return UnbanResponse(success=True, message=f'User {request.username} added to whitelist')
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -867,7 +869,7 @@ async def whitelist_remove(
|
||||
api = _get_ban_api()
|
||||
try:
|
||||
await _api_request(api, 'whitelist_remove', username=request.username)
|
||||
logger.info(f'Admin {admin.id} removed {request.username} from Ban System whitelist')
|
||||
logger.info('Admin removed from Ban System whitelist', admin_id=admin.id, username=request.username)
|
||||
return UnbanResponse(success=True, message=f'User {request.username} removed from whitelist')
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Admin routes for broadcasts in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -40,7 +40,7 @@ from ..schemas.broadcasts import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/broadcasts', tags=['Cabinet Admin Broadcasts'])
|
||||
|
||||
@@ -255,7 +255,7 @@ async def get_filters(
|
||||
try:
|
||||
count = await get_target_users_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get count for filter {key}: {e}')
|
||||
logger.warning('Failed to get count for filter', key=key, error=e)
|
||||
count = 0
|
||||
filters.append(
|
||||
BroadcastFilter(
|
||||
@@ -272,7 +272,7 @@ async def get_filters(
|
||||
try:
|
||||
count = await get_target_users_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get count for custom filter {key}: {e}')
|
||||
logger.warning('Failed to get count for custom filter', key=key, error=e)
|
||||
count = 0
|
||||
custom_filters.append(
|
||||
BroadcastFilter(
|
||||
@@ -367,7 +367,7 @@ async def preview_broadcast(
|
||||
try:
|
||||
count = await get_target_users_count(db, request.target)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get count for target {request.target}: {e}')
|
||||
logger.error('Failed to get count for target', target=request.target, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to count recipients',
|
||||
@@ -450,7 +450,9 @@ async def create_broadcast(
|
||||
await broadcast_service.start_broadcast(broadcast.id, config)
|
||||
await db.refresh(broadcast)
|
||||
|
||||
logger.info(f"Admin {admin.id} created broadcast {broadcast.id} for target '{request.target}'")
|
||||
logger.info(
|
||||
'Admin created broadcast for target', admin_id=admin.id, broadcast_id=broadcast.id, target=request.target
|
||||
)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
@@ -494,7 +496,7 @@ async def get_email_filters(
|
||||
try:
|
||||
count = await _get_email_filter_count(db, key)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get count for email filter {key}: {e}')
|
||||
logger.warning('Failed to get count for email filter', key=key, error=e)
|
||||
count = 0
|
||||
|
||||
filters.append(
|
||||
@@ -532,7 +534,7 @@ async def preview_email_broadcast(
|
||||
try:
|
||||
count = await _get_email_filter_count(db, request.target)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get email count for target {request.target}: {e}')
|
||||
logger.error('Failed to get email count for target', target=request.target, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to count email recipients',
|
||||
@@ -661,7 +663,13 @@ async def create_combined_broadcast(
|
||||
|
||||
await db.refresh(broadcast)
|
||||
|
||||
logger.info(f"Admin {admin.id} created {request.channel} broadcast {broadcast.id} for target '{request.target}'")
|
||||
logger.info(
|
||||
'Admin created broadcast for target',
|
||||
admin_id=admin.id,
|
||||
channel=request.channel,
|
||||
broadcast_id=broadcast.id,
|
||||
target=request.target,
|
||||
)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
@@ -721,6 +729,6 @@ async def stop_broadcast(
|
||||
await db.commit()
|
||||
await db.refresh(broadcast)
|
||||
|
||||
logger.info(f'Admin {admin.id} stopped broadcast {broadcast_id}')
|
||||
logger.info('Admin stopped broadcast', admin_id=admin.id, broadcast_id=broadcast_id)
|
||||
|
||||
return _serialize_broadcast(broadcast)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Admin routes for per-section cabinet button style configuration."""
|
||||
|
||||
import json
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.utils.button_styles_cache import (
|
||||
ALLOWED_STYLE_VALUES,
|
||||
BOT_LOCALES,
|
||||
BUTTON_STYLES_KEY,
|
||||
DEFAULT_BUTTON_STYLES,
|
||||
SECTIONS,
|
||||
load_button_styles_cache,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
|
||||
|
||||
|
||||
# ---- Schemas ---------------------------------------------------------------
|
||||
|
||||
|
||||
class ButtonSectionConfig(BaseModel):
|
||||
"""Configuration for a single button section."""
|
||||
|
||||
style: str = 'primary'
|
||||
icon_custom_emoji_id: str = ''
|
||||
enabled: bool = True
|
||||
labels: dict[str, str] = {}
|
||||
|
||||
|
||||
class ButtonStylesResponse(BaseModel):
|
||||
"""Full button styles configuration (all 7 sections)."""
|
||||
|
||||
home: ButtonSectionConfig = ButtonSectionConfig()
|
||||
subscription: ButtonSectionConfig = ButtonSectionConfig()
|
||||
balance: ButtonSectionConfig = ButtonSectionConfig()
|
||||
referral: ButtonSectionConfig = ButtonSectionConfig()
|
||||
support: ButtonSectionConfig = ButtonSectionConfig()
|
||||
info: ButtonSectionConfig = ButtonSectionConfig()
|
||||
admin: ButtonSectionConfig = ButtonSectionConfig()
|
||||
|
||||
|
||||
MAX_LABEL_LENGTH = 100
|
||||
|
||||
|
||||
class ButtonSectionUpdate(BaseModel):
|
||||
"""Partial update for a single section (None = keep current)."""
|
||||
|
||||
style: str | None = None
|
||||
icon_custom_emoji_id: str | None = None
|
||||
enabled: bool | None = None
|
||||
labels: dict[str, str] | None = None
|
||||
|
||||
|
||||
class ButtonStylesUpdate(BaseModel):
|
||||
"""Partial update — only include sections you want to change."""
|
||||
|
||||
home: ButtonSectionUpdate | None = None
|
||||
subscription: ButtonSectionUpdate | None = None
|
||||
balance: ButtonSectionUpdate | None = None
|
||||
referral: ButtonSectionUpdate | None = None
|
||||
support: ButtonSectionUpdate | None = None
|
||||
info: ButtonSectionUpdate | None = None
|
||||
admin: ButtonSectionUpdate | None = None
|
||||
|
||||
|
||||
# ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting.value if setting else None
|
||||
|
||||
|
||||
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database.models import SystemSetting
|
||||
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=value)
|
||||
db.add(setting)
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
|
||||
return ButtonStylesResponse(
|
||||
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
|
||||
)
|
||||
|
||||
|
||||
# ---- Routes ----------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get('', response_model=ButtonStylesResponse)
|
||||
async def get_button_styles(
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return current per-section button styles. Admin only."""
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
merged = {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
|
||||
|
||||
if raw:
|
||||
try:
|
||||
db_data = json.loads(raw)
|
||||
for section, overrides in db_data.items():
|
||||
if section in merged and isinstance(overrides, dict):
|
||||
if overrides.get('style') in ALLOWED_STYLE_VALUES:
|
||||
merged[section]['style'] = overrides['style']
|
||||
if isinstance(overrides.get('icon_custom_emoji_id'), str):
|
||||
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
|
||||
if isinstance(overrides.get('enabled'), bool):
|
||||
merged[section]['enabled'] = overrides['enabled']
|
||||
if isinstance(overrides.get('labels'), dict):
|
||||
merged[section]['labels'] = {
|
||||
k: v
|
||||
for k, v in overrides['labels'].items()
|
||||
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
|
||||
}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return _build_response(merged)
|
||||
|
||||
|
||||
@router.patch('', response_model=ButtonStylesResponse)
|
||||
async def update_button_styles(
|
||||
payload: ButtonStylesUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Partially update per-section button styles. Admin only."""
|
||||
# Load current state
|
||||
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
|
||||
current: dict[str, dict] = {
|
||||
section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()
|
||||
}
|
||||
|
||||
if raw:
|
||||
try:
|
||||
db_data = json.loads(raw)
|
||||
for section, overrides in db_data.items():
|
||||
if section in current and isinstance(overrides, dict):
|
||||
if overrides.get('style') in ALLOWED_STYLE_VALUES:
|
||||
current[section]['style'] = overrides['style']
|
||||
if isinstance(overrides.get('icon_custom_emoji_id'), str):
|
||||
current[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
|
||||
if isinstance(overrides.get('enabled'), bool):
|
||||
current[section]['enabled'] = overrides['enabled']
|
||||
if isinstance(overrides.get('labels'), dict):
|
||||
current[section]['labels'] = {
|
||||
k: v
|
||||
for k, v in overrides['labels'].items()
|
||||
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
|
||||
}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Apply updates
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
changed_sections: list[str] = []
|
||||
|
||||
for section, updates in update_data.items():
|
||||
if section not in current or not isinstance(updates, dict):
|
||||
continue
|
||||
|
||||
if 'style' in updates:
|
||||
style_val = updates['style']
|
||||
if style_val not in ALLOWED_STYLE_VALUES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid style "{style_val}" for section "{section}". '
|
||||
f'Allowed: {", ".join(sorted(ALLOWED_STYLE_VALUES))}',
|
||||
)
|
||||
current[section]['style'] = style_val
|
||||
|
||||
if 'icon_custom_emoji_id' in updates:
|
||||
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
|
||||
current[section]['icon_custom_emoji_id'] = emoji_val
|
||||
|
||||
if 'enabled' in updates:
|
||||
current[section]['enabled'] = updates['enabled']
|
||||
|
||||
if 'labels' in updates:
|
||||
raw_labels = updates['labels'] or {}
|
||||
sanitized: dict[str, str] = {}
|
||||
for locale_key, label_val in raw_labels.items():
|
||||
if locale_key not in BOT_LOCALES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid locale "{locale_key}" for section "{section}". '
|
||||
f'Allowed: {", ".join(BOT_LOCALES)}',
|
||||
)
|
||||
if not isinstance(label_val, str):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label value for locale "{locale_key}" must be a string.',
|
||||
)
|
||||
stripped = label_val.strip()
|
||||
if len(stripped) > MAX_LABEL_LENGTH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Label for locale "{locale_key}" exceeds {MAX_LABEL_LENGTH} characters.',
|
||||
)
|
||||
# Empty string = remove custom label (use default)
|
||||
if stripped:
|
||||
sanitized[locale_key] = stripped
|
||||
current[section]['labels'] = sanitized
|
||||
|
||||
changed_sections.append(section)
|
||||
|
||||
# Persist
|
||||
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
|
||||
|
||||
# Refresh in-process cache
|
||||
await load_button_styles_cache()
|
||||
|
||||
logger.info(
|
||||
'Admin updated button styles for sections', telegram_id=admin.telegram_id, changed_sections=changed_sections
|
||||
)
|
||||
|
||||
return _build_response(current)
|
||||
|
||||
|
||||
@router.post('/reset', response_model=ButtonStylesResponse)
|
||||
async def reset_button_styles(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all button styles to defaults. Admin only."""
|
||||
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
|
||||
await load_button_styles_cache()
|
||||
|
||||
logger.info('Admin reset button styles to defaults', telegram_id=admin.telegram_id)
|
||||
|
||||
return _build_response(DEFAULT_BUTTON_STYLES)
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Admin routes for managing advertising campaigns in cabinet."""
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -45,7 +44,7 @@ from ..schemas.campaigns import (
|
||||
from ..schemas.tariffs import TariffListItem
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/campaigns', tags=['Cabinet Admin Campaigns'])
|
||||
|
||||
@@ -378,7 +377,7 @@ async def create_new_campaign(
|
||||
# Reload to get tariff relationship
|
||||
campaign = await get_campaign_by_id(db, campaign.id)
|
||||
|
||||
logger.info(f'Admin {admin.id} created campaign {campaign.id}: {campaign.name}')
|
||||
logger.info('Admin created campaign', admin_id=admin.id, campaign_id=campaign.id, campaign_name=campaign.name)
|
||||
|
||||
return await get_campaign(campaign.id, admin, db)
|
||||
|
||||
@@ -447,7 +446,7 @@ async def update_existing_campaign(
|
||||
if updates:
|
||||
await update_campaign(db, campaign, **updates)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated campaign {campaign_id}')
|
||||
logger.info('Admin updated campaign', admin_id=admin.id, campaign_id=campaign_id)
|
||||
|
||||
return await get_campaign(campaign_id, admin, db)
|
||||
|
||||
@@ -475,7 +474,7 @@ async def delete_existing_campaign(
|
||||
)
|
||||
|
||||
await delete_campaign(db, campaign)
|
||||
logger.info(f'Admin {admin.id} deleted campaign {campaign_id}: {campaign.name}')
|
||||
logger.info('Admin deleted campaign', admin_id=admin.id, campaign_id=campaign_id, campaign_name=campaign.name)
|
||||
|
||||
return {'message': 'Campaign deleted successfully'}
|
||||
|
||||
@@ -498,7 +497,7 @@ async def toggle_campaign(
|
||||
await update_campaign(db, campaign, is_active=new_status)
|
||||
|
||||
status_text = 'activated' if new_status else 'deactivated'
|
||||
logger.info(f'Admin {admin.id} {status_text} campaign {campaign_id}')
|
||||
logger.info('Admin campaign', admin_id=admin.id, status_text=status_text, campaign_id=campaign_id)
|
||||
|
||||
return CampaignToggleResponse(
|
||||
id=campaign_id,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin routes for managing email notification templates."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -20,7 +20,7 @@ from ..services.email_template_overrides import (
|
||||
from ..services.email_templates import EmailNotificationTemplates
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/email-templates', tags=['Admin Email Templates'])
|
||||
|
||||
@@ -337,7 +337,7 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
|
||||
}
|
||||
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua']
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
@@ -505,10 +505,7 @@ async def update_template(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Админ %s обновил email шаблон %s/%s',
|
||||
admin.id,
|
||||
notification_type,
|
||||
language,
|
||||
'Админ обновил email шаблон /', admin_id=admin.id, notification_type=notification_type, language=language
|
||||
)
|
||||
|
||||
return {'status': 'ok', 'template': result}
|
||||
@@ -533,10 +530,10 @@ async def reset_template(
|
||||
|
||||
if deleted:
|
||||
logger.info(
|
||||
'Админ %s сбросил email шаблон %s/%s к дефолту',
|
||||
admin.id,
|
||||
notification_type,
|
||||
language,
|
||||
'Админ сбросил email шаблон / к дефолту',
|
||||
admin_id=admin.id,
|
||||
notification_type=notification_type,
|
||||
language=language,
|
||||
)
|
||||
|
||||
return {'status': 'ok', 'was_custom': deleted}
|
||||
@@ -657,7 +654,7 @@ async def send_test_email(
|
||||
body_html=body_html,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки тестового email: %s', e)
|
||||
logger.error('Ошибка отправки тестового email', e=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to send test email: {e!s}',
|
||||
@@ -670,11 +667,11 @@ async def send_test_email(
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Админ %s отправил тестовый email %s/%s на %s',
|
||||
admin.id,
|
||||
notification_type,
|
||||
language,
|
||||
to_email,
|
||||
'Админ отправил тестовый email / на',
|
||||
admin_id=admin.id,
|
||||
notification_type=notification_type,
|
||||
language=language,
|
||||
to_email=to_email,
|
||||
)
|
||||
|
||||
return {'status': 'ok', 'sent_to': to_email}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Admin routes for payment method configuration in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -20,7 +20,7 @@ from app.services.payment_method_config_service import (
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/payment-methods', tags=['Cabinet Admin Payment Methods'])
|
||||
|
||||
@@ -168,7 +168,7 @@ async def update_payment_methods_order(
|
||||
):
|
||||
"""Batch update sort order for payment methods."""
|
||||
await update_sort_order(db, request.method_ids)
|
||||
logger.info(f'Admin {admin.id} updated payment methods order: {request.method_ids}')
|
||||
logger.info('Admin updated payment methods order', admin_id=admin.id, method_ids=request.method_ids)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ async def update_payment_method(
|
||||
detail=f'Payment method not found: {method_id}',
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated payment method config: {method_id}')
|
||||
logger.info('Admin updated payment method config', admin_id=admin.id, method_id=method_id)
|
||||
|
||||
defaults = _get_method_defaults()
|
||||
return _enrich_config(config, defaults)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin routes for payment verification in cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,7 +22,7 @@ from app.services.payment_verification_service import (
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments'])
|
||||
|
||||
@@ -406,7 +406,14 @@ async def check_payment_status(
|
||||
if status_changed:
|
||||
_, new_status_text = _get_status_info(updated)
|
||||
message = f'Статус обновлён: {new_status_text}'
|
||||
logger.info(f'Admin {admin.id} checked payment {method}/{payment_id}: {old_status} -> {updated.status}')
|
||||
logger.info(
|
||||
'Admin checked payment /',
|
||||
admin_id=admin.id,
|
||||
method=method,
|
||||
payment_id=payment_id,
|
||||
old_status=old_status,
|
||||
status=updated.status,
|
||||
)
|
||||
else:
|
||||
message = 'Статус не изменился'
|
||||
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
"""Admin routes for pinned messages in cabinet."""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import PinnedMessage, User
|
||||
from app.services.pinned_message_service import (
|
||||
broadcast_pinned_message,
|
||||
deactivate_active_pinned_message,
|
||||
get_active_pinned_message,
|
||||
set_active_pinned_message,
|
||||
unpin_active_pinned_message,
|
||||
)
|
||||
from app.utils.validators import sanitize_html, validate_html_tags
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.pinned_messages import (
|
||||
PinnedMessageBroadcastResponse,
|
||||
PinnedMessageCreateRequest,
|
||||
PinnedMessageListResponse,
|
||||
PinnedMessageResponse,
|
||||
PinnedMessageSettingsRequest,
|
||||
PinnedMessageUnpinResponse,
|
||||
PinnedMessageUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages'])
|
||||
|
||||
# Broadcast cooldown: min 60 seconds between mass operations
|
||||
_BROADCAST_COOLDOWN_SECONDS = 60
|
||||
_last_broadcast_time: float = 0.0
|
||||
|
||||
|
||||
def _check_broadcast_cooldown() -> None:
|
||||
global _last_broadcast_time
|
||||
now = time.monotonic()
|
||||
elapsed = now - _last_broadcast_time
|
||||
if _last_broadcast_time > 0 and elapsed < _BROADCAST_COOLDOWN_SECONDS:
|
||||
remaining = int(_BROADCAST_COOLDOWN_SECONDS - elapsed)
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
f'Broadcast cooldown active. Try again in {remaining} seconds.',
|
||||
)
|
||||
_last_broadcast_time = now
|
||||
|
||||
|
||||
def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse:
|
||||
return PinnedMessageResponse(
|
||||
id=msg.id,
|
||||
content=msg.content,
|
||||
media_type=msg.media_type,
|
||||
media_file_id=msg.media_file_id,
|
||||
send_before_menu=msg.send_before_menu,
|
||||
send_on_every_start=msg.send_on_every_start,
|
||||
is_active=msg.is_active,
|
||||
created_by=msg.created_by,
|
||||
created_at=msg.created_at,
|
||||
updated_at=msg.updated_at,
|
||||
)
|
||||
|
||||
|
||||
_cached_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_bot() -> Bot:
|
||||
global _cached_bot
|
||||
if _cached_bot is None:
|
||||
_cached_bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
return _cached_bot
|
||||
|
||||
|
||||
# ============ List / Get Endpoints ============
|
||||
|
||||
|
||||
@router.get('', response_model=PinnedMessageListResponse)
|
||||
async def list_pinned_messages(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
active_only: bool = Query(False),
|
||||
) -> PinnedMessageListResponse:
|
||||
"""Get list of pinned messages with pagination."""
|
||||
query = select(PinnedMessage).order_by(PinnedMessage.created_at.desc())
|
||||
count_query = select(func.count(PinnedMessage.id))
|
||||
|
||||
if active_only:
|
||||
query = query.where(PinnedMessage.is_active.is_(True))
|
||||
count_query = count_query.where(PinnedMessage.is_active.is_(True))
|
||||
|
||||
total = await db.scalar(count_query) or 0
|
||||
result = await db.execute(query.offset(offset).limit(limit))
|
||||
items = result.scalars().all()
|
||||
|
||||
return PinnedMessageListResponse(
|
||||
items=[_serialize_pinned_message(msg) for msg in items],
|
||||
total=int(total),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/active', response_model=PinnedMessageResponse | None)
|
||||
async def get_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Get current active pinned message."""
|
||||
msg = await get_active_pinned_message(db)
|
||||
if not msg:
|
||||
return None
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.get('/{message_id}', response_model=PinnedMessageResponse)
|
||||
async def get_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Get pinned message by ID."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
# ============ Create / Update Endpoints ============
|
||||
|
||||
|
||||
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_pinned_message(
|
||||
payload: PinnedMessageCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
Create a new pinned message.
|
||||
|
||||
Automatically deactivates previous active message.
|
||||
If broadcast=true, sends to all active users immediately.
|
||||
"""
|
||||
# Проверяем cooldown ДО мутации в БД
|
||||
if payload.broadcast:
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
content = payload.content.strip()
|
||||
if not content and not payload.media:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Either content or media must be provided')
|
||||
|
||||
media_type = payload.media.type if payload.media else None
|
||||
media_file_id = payload.media.file_id if payload.media else None
|
||||
|
||||
try:
|
||||
msg = await set_active_pinned_message(
|
||||
db=db,
|
||||
content=content,
|
||||
created_by=admin.id,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
send_before_menu=payload.send_before_menu,
|
||||
send_on_every_start=payload.send_on_every_start,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
if payload.broadcast:
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(
|
||||
'Admin created pinned message # (broadcast=)', admin_id=admin.id, message_id=msg.id, broadcast=payload.broadcast
|
||||
)
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/{message_id}', response_model=PinnedMessageResponse)
|
||||
async def update_pinned_message(
|
||||
message_id: int,
|
||||
payload: PinnedMessageUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update a pinned message content, media, or settings."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if payload.content is not None:
|
||||
sanitized = sanitize_html(payload.content)
|
||||
is_valid, error = validate_html_tags(sanitized)
|
||||
if not is_valid:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, error)
|
||||
msg.content = sanitized
|
||||
|
||||
if payload.media is not None:
|
||||
msg.media_type = payload.media.type
|
||||
msg.media_file_id = payload.media.file_id
|
||||
|
||||
if payload.send_before_menu is not None:
|
||||
msg.send_before_menu = payload.send_before_menu
|
||||
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
logger.info('Admin updated pinned message #', admin_id=admin.id, message_id=message_id)
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.patch('/{message_id}/settings', response_model=PinnedMessageResponse)
|
||||
async def update_pinned_message_settings(
|
||||
message_id: int,
|
||||
payload: PinnedMessageSettingsRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update only pinned message display settings."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if payload.send_before_menu is not None:
|
||||
msg.send_before_menu = payload.send_before_menu
|
||||
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
# ============ Active Message Actions (before /{message_id} POST routes) ============
|
||||
|
||||
|
||||
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
|
||||
async def deactivate_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Deactivate the current active pinned message without unpinning from users."""
|
||||
msg = await deactivate_active_pinned_message(db)
|
||||
if not msg:
|
||||
return None
|
||||
|
||||
logger.info('Admin deactivated pinned message #', admin_id=admin.id, message_id=msg.id)
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
|
||||
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
|
||||
async def unpin_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageUnpinResponse:
|
||||
"""Unpin messages from all users and deactivate the active pinned message."""
|
||||
_check_broadcast_cooldown()
|
||||
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
|
||||
|
||||
if was_active:
|
||||
logger.info(
|
||||
'Admin unpinned active message: unpinned=, failed',
|
||||
admin_id=admin.id,
|
||||
unpinned_count=unpinned_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
return PinnedMessageUnpinResponse(
|
||||
unpinned_count=unpinned_count,
|
||||
failed_count=failed_count,
|
||||
was_active=was_active,
|
||||
)
|
||||
|
||||
|
||||
# ============ Per-Message Actions ============
|
||||
|
||||
|
||||
@router.post('/{message_id}/activate', response_model=PinnedMessageBroadcastResponse)
|
||||
async def activate_pinned_message(
|
||||
message_id: int,
|
||||
broadcast: bool = Query(False),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
Activate a pinned message.
|
||||
|
||||
Deactivates the current active message and activates the specified one.
|
||||
If broadcast=true, sends to all active users immediately.
|
||||
"""
|
||||
# Проверяем cooldown ДО мутации в БД
|
||||
if broadcast:
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
await db.execute(
|
||||
update(PinnedMessage)
|
||||
.where(PinnedMessage.is_active.is_(True))
|
||||
.values(is_active=False, updated_at=datetime.utcnow())
|
||||
)
|
||||
|
||||
msg.is_active = True
|
||||
msg.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
if broadcast:
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(
|
||||
'Admin activated pinned message # (broadcast=)', admin_id=admin.id, message_id=message_id, broadcast=broadcast
|
||||
)
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
|
||||
async def broadcast_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""Broadcast a pinned message to all active users."""
|
||||
_check_broadcast_cooldown()
|
||||
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(
|
||||
'Admin broadcast pinned message #: sent=, failed',
|
||||
admin_id=admin.id,
|
||||
message_id=message_id,
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
|
||||
async def delete_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a pinned message. Active messages must be deactivated first."""
|
||||
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
|
||||
|
||||
if msg.is_active:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
'Cannot delete active pinned message. Deactivate it first.',
|
||||
)
|
||||
|
||||
await db.delete(msg)
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin deleted pinned message #', admin_id=admin.id, message_id=message_id)
|
||||
@@ -3,10 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -37,7 +37,7 @@ from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers'])
|
||||
|
||||
@@ -430,7 +430,7 @@ async def _send_promo_notifications(
|
||||
async def send_single(user: User, offer: DiscountOffer) -> bool:
|
||||
# Skip email-only users (no telegram_id)
|
||||
if not user.telegram_id:
|
||||
logger.debug(f'Skipping promo notification for email-only user {user.id}')
|
||||
logger.debug('Skipping promo notification for email-only user', user_id=user.id)
|
||||
return False
|
||||
|
||||
async with semaphore:
|
||||
@@ -459,18 +459,10 @@ async def _send_promo_notifications(
|
||||
)
|
||||
return True
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
logger.warning(
|
||||
'Failed to send promo notification to user %s: %s',
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
logger.warning('Failed to send promo notification to user', telegram_id=user.telegram_id, exc=exc)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
'Error sending promo notification to user %s: %s',
|
||||
user.telegram_id,
|
||||
exc,
|
||||
)
|
||||
logger.error('Error sending promo notification to user', telegram_id=user.telegram_id, exc=exc)
|
||||
return False
|
||||
|
||||
# Send in batches
|
||||
|
||||
@@ -363,13 +363,16 @@ async def create_promocode_endpoint(
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
|
||||
|
||||
# 0 means unlimited — convert to large number for is_valid check (current_uses < max_uses)
|
||||
effective_max_uses = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
promocode = await create_promocode(
|
||||
db,
|
||||
code=normalized_code,
|
||||
type=payload.type,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_days=payload.subscription_days,
|
||||
max_uses=payload.max_uses,
|
||||
max_uses=effective_max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=admin.id,
|
||||
)
|
||||
@@ -426,7 +429,7 @@ async def update_promocode_endpoint(
|
||||
updates['subscription_days'] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
updates['max_uses'] = payload.max_uses
|
||||
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
if payload.valid_from is not None:
|
||||
updates['valid_from'] = _normalize_datetime(payload.valid_from)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin routes for RemnaWave management in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -76,7 +76,7 @@ except Exception:
|
||||
remnawave_sync_service = None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/remnawave', tags=['Cabinet Admin RemnaWave'])
|
||||
|
||||
@@ -380,7 +380,9 @@ async def perform_node_action(
|
||||
}
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} performed {payload.action} on node {node_uuid}')
|
||||
logger.info(
|
||||
'Admin performed on node', telegram_id=admin.telegram_id, action=payload.action, node_uuid=node_uuid
|
||||
)
|
||||
return NodeActionResponse(
|
||||
success=True,
|
||||
message=messages.get(payload.action, 'Action completed'),
|
||||
@@ -403,7 +405,7 @@ async def restart_all_nodes(
|
||||
success = await service.restart_all_nodes()
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} restarted all nodes')
|
||||
logger.info('Admin restarted all nodes', telegram_id=admin.telegram_id)
|
||||
return NodeActionResponse(success=True, message='All nodes restart initiated')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -510,7 +512,9 @@ async def create_squad(
|
||||
squad_uuid = await service.create_squad(payload.name, payload.inbound_uuids)
|
||||
|
||||
if squad_uuid:
|
||||
logger.info(f'Admin {admin.telegram_id} created squad {payload.name} ({squad_uuid})')
|
||||
logger.info(
|
||||
'Admin created squad', telegram_id=admin.telegram_id, payload_name=payload.name, squad_uuid=squad_uuid
|
||||
)
|
||||
return SquadOperationResponse(
|
||||
success=True,
|
||||
message='Squad created successfully',
|
||||
@@ -545,7 +549,7 @@ async def update_squad(
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} updated squad {squad_uuid}')
|
||||
logger.info('Admin updated squad', telegram_id=admin.telegram_id, squad_uuid=squad_uuid)
|
||||
return SquadOperationResponse(success=True, message='Squad updated')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -594,7 +598,7 @@ async def perform_squad_action(
|
||||
message = 'Inbounds updated' if success else 'Failed to update inbounds'
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} performed {action} on squad {squad_uuid}')
|
||||
logger.info('Admin performed on squad', telegram_id=admin.telegram_id, action=action, squad_uuid=squad_uuid)
|
||||
|
||||
return SquadOperationResponse(success=success, message=message)
|
||||
|
||||
@@ -611,7 +615,7 @@ async def delete_squad(
|
||||
success = await service.delete_squad(squad_uuid)
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.telegram_id} deleted squad {squad_uuid}')
|
||||
logger.info('Admin deleted squad', telegram_id=admin.telegram_id, squad_uuid=squad_uuid)
|
||||
return SquadOperationResponse(success=True, message='Squad deleted')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -699,7 +703,9 @@ async def migrate_squad_users(
|
||||
error=result.get('error'),
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} migrated users from {source_uuid} to {target_uuid}')
|
||||
logger.info(
|
||||
'Admin migrated users from to', telegram_id=admin.telegram_id, source_uuid=source_uuid, target_uuid=target_uuid
|
||||
)
|
||||
|
||||
return MigrationResponse(
|
||||
success=True,
|
||||
@@ -782,14 +788,14 @@ async def toggle_auto_sync(
|
||||
if payload.enabled and not current_status.enabled:
|
||||
# Enable - would need to update settings and refresh schedule
|
||||
remnawave_sync_service.schedule_refresh(run_immediately=True)
|
||||
logger.info(f'Admin {admin.telegram_id} enabled auto sync')
|
||||
logger.info('Admin enabled auto sync', telegram_id=admin.telegram_id)
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Auto sync enabled and scheduled',
|
||||
)
|
||||
if not payload.enabled and current_status.enabled:
|
||||
# Disable - would need to update settings and stop scheduler
|
||||
logger.info(f'Admin {admin.telegram_id} disabled auto sync')
|
||||
logger.info('Admin disabled auto sync', telegram_id=admin.telegram_id)
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Auto sync setting change requested. Restart may be required.',
|
||||
@@ -811,7 +817,7 @@ async def run_auto_sync_now(
|
||||
detail='Auto sync service is not available',
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} triggered manual sync')
|
||||
logger.info('Admin triggered manual sync', telegram_id=admin.telegram_id)
|
||||
result = await remnawave_sync_service.run_sync_now(reason='manual')
|
||||
|
||||
return AutoSyncRunResponse(
|
||||
@@ -839,7 +845,7 @@ async def sync_from_panel(
|
||||
|
||||
try:
|
||||
stats = await service.sync_users_from_panel(db, payload.mode)
|
||||
logger.info(f'Admin {admin.telegram_id} synced from panel (mode: {payload.mode})')
|
||||
logger.info('Admin synced from panel (mode: )', telegram_id=admin.telegram_id, mode=payload.mode)
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
message='Sync from panel completed',
|
||||
@@ -862,7 +868,7 @@ async def sync_to_panel(
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.sync_users_to_panel(db)
|
||||
logger.info(f'Admin {admin.telegram_id} synced to panel')
|
||||
logger.info('Admin synced to panel', telegram_id=admin.telegram_id)
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
@@ -892,9 +898,15 @@ async def sync_servers(
|
||||
try:
|
||||
await cache.delete_pattern('available_countries*')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to clear countries cache: {e}')
|
||||
logger.warning('Failed to clear countries cache', error=e)
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} synced servers: created={created}, updated={updated}, removed={removed}')
|
||||
logger.info(
|
||||
'Admin synced servers: created=, updated=, removed',
|
||||
telegram_id=admin.telegram_id,
|
||||
created=created,
|
||||
updated=updated,
|
||||
removed=removed,
|
||||
)
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
@@ -918,7 +930,7 @@ async def validate_subscriptions(
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.validate_and_fix_subscriptions(db)
|
||||
logger.info(f'Admin {admin.telegram_id} validated subscriptions')
|
||||
logger.info('Admin validated subscriptions', telegram_id=admin.telegram_id)
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
@@ -937,7 +949,7 @@ async def cleanup_subscriptions(
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.cleanup_orphaned_subscriptions(db)
|
||||
logger.info(f'Admin {admin.telegram_id} cleaned up subscriptions')
|
||||
logger.info('Admin cleaned up subscriptions', telegram_id=admin.telegram_id)
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
@@ -956,7 +968,7 @@ async def sync_subscription_statuses(
|
||||
_ensure_configured(service)
|
||||
|
||||
stats = await service.sync_subscription_statuses(db)
|
||||
logger.info(f'Admin {admin.telegram_id} synced subscription statuses')
|
||||
logger.info('Admin synced subscription statuses', telegram_id=admin.telegram_id)
|
||||
|
||||
return SyncResponse(
|
||||
success=True,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Admin routes for managing servers in cabinet."""
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import String, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -31,7 +30,7 @@ from ..schemas.servers import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/servers', tags=['Cabinet Admin Servers'])
|
||||
|
||||
@@ -184,7 +183,7 @@ async def update_existing_server(
|
||||
if request.promo_group_ids is not None:
|
||||
await update_server_squad_promo_groups(db, server_id, request.promo_group_ids)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated server {server_id}')
|
||||
logger.info('Admin updated server', admin_id=admin.id, server_id=server_id)
|
||||
|
||||
return await get_server(server_id, admin, db)
|
||||
|
||||
@@ -207,7 +206,7 @@ async def toggle_server(
|
||||
await update_server_squad(db, server_id, is_available=new_status)
|
||||
|
||||
status_text = 'enabled' if new_status else 'disabled'
|
||||
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
|
||||
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
|
||||
|
||||
return ServerToggleResponse(
|
||||
id=server_id,
|
||||
@@ -234,7 +233,7 @@ async def toggle_server_trial(
|
||||
await update_server_squad(db, server_id, is_trial_eligible=new_status)
|
||||
|
||||
status_text = 'enabled for trial' if new_status else 'disabled for trial'
|
||||
logger.info(f'Admin {admin.id} {status_text} server {server_id}')
|
||||
logger.info('Admin server', admin_id=admin.id, status_text=status_text, server_id=server_id)
|
||||
|
||||
return ServerTrialToggleResponse(
|
||||
id=server_id,
|
||||
@@ -311,7 +310,7 @@ async def sync_servers(
|
||||
# Sync with database
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
|
||||
logger.info(f'Admin {admin.id} synced servers: +{created} ~{updated} -{removed}')
|
||||
logger.info('Admin synced servers: + ~', admin_id=admin.id, created=created, updated=updated, removed=removed)
|
||||
|
||||
return ServerSyncResponse(
|
||||
created=created,
|
||||
@@ -323,7 +322,7 @@ async def sync_servers(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to sync servers: {e}')
|
||||
logger.error('Failed to sync servers', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Sync failed: {e!s}',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Admin settings routes for cabinet - system configuration management."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,7 +16,7 @@ from app.services.system_settings_service import (
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/settings', tags=['Admin Settings'])
|
||||
|
||||
@@ -248,7 +248,7 @@ async def update_setting(
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} updated setting {key} to {value}')
|
||||
logger.info('Admin updated setting to', telegram_id=admin.telegram_id, key=key, value=value)
|
||||
return _serialize_definition(definition)
|
||||
|
||||
|
||||
@@ -270,5 +270,5 @@ async def reset_setting(
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} reset setting {key}')
|
||||
logger.info('Admin reset setting', telegram_id=admin.telegram_id, key=key)
|
||||
return _serialize_definition(definition)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Admin routes for statistics dashboard in cabinet."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, func, select
|
||||
@@ -22,11 +24,14 @@ from app.database.models import (
|
||||
User,
|
||||
)
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
router = APIRouter(prefix='/admin/stats', tags=['Cabinet Admin Stats'])
|
||||
|
||||
@@ -142,6 +147,16 @@ class DashboardStats(BaseModel):
|
||||
tariff_stats: TariffStats | None = None
|
||||
|
||||
|
||||
class SystemInfoResponse(BaseModel):
|
||||
"""System information for admin dashboard."""
|
||||
|
||||
bot_version: str
|
||||
python_version: str
|
||||
uptime_seconds: int
|
||||
users_total: int
|
||||
subscriptions_active: int
|
||||
|
||||
|
||||
# ============ Extended Stats Schemas ============
|
||||
|
||||
|
||||
@@ -302,13 +317,45 @@ async def get_dashboard_stats(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get dashboard stats: {e}')
|
||||
logger.error('Failed to get dashboard stats', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load dashboard statistics',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/system-info', response_model=SystemInfoResponse)
|
||||
async def get_system_info(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get system information for admin dashboard."""
|
||||
try:
|
||||
users_total_result = await db.execute(select(func.count()).select_from(User))
|
||||
users_total = users_total_result.scalar() or 0
|
||||
|
||||
subs_active_result = await db.execute(
|
||||
select(func.count(Subscription.id)).where(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
subscriptions_active = subs_active_result.scalar() or 0
|
||||
|
||||
return SystemInfoResponse(
|
||||
bot_version=version_service.current_version,
|
||||
python_version=sys.version.split()[0],
|
||||
uptime_seconds=int(time.time() - _start_time),
|
||||
users_total=users_total,
|
||||
subscriptions_active=subscriptions_active,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to get system info', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load system information',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/nodes', response_model=NodesOverview)
|
||||
async def get_nodes_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
@@ -317,7 +364,7 @@ async def get_nodes_status(
|
||||
try:
|
||||
return await _get_nodes_overview()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get nodes status: {e}')
|
||||
logger.error('Failed to get nodes status', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load nodes status',
|
||||
@@ -335,7 +382,7 @@ async def restart_node(
|
||||
success = await service.manage_node(node_uuid, 'restart')
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.id} restarted node {node_uuid}')
|
||||
logger.info('Admin restarted node', admin_id=admin.id, node_uuid=node_uuid)
|
||||
return {'success': True, 'message': 'Node restart initiated'}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -344,7 +391,7 @@ async def restart_node(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to restart node {node_uuid}: {e}')
|
||||
logger.error('Failed to restart node', node_uuid=node_uuid, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to restart node',
|
||||
@@ -373,7 +420,7 @@ async def toggle_node(
|
||||
success = await service.manage_node(node_uuid, action)
|
||||
|
||||
if success:
|
||||
logger.info(f'Admin {admin.id} {action}d node {node_uuid}')
|
||||
logger.info('Admin d node', admin_id=admin.id, action=action, node_uuid=node_uuid)
|
||||
return {'success': True, 'message': f'Node {action}d', 'is_disabled': not is_disabled}
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -382,7 +429,7 @@ async def toggle_node(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to toggle node {node_uuid}: {e}')
|
||||
logger.error('Failed to toggle node', node_uuid=node_uuid, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to toggle node',
|
||||
@@ -433,7 +480,7 @@ async def _get_nodes_overview() -> NodesOverview:
|
||||
nodes=node_statuses,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get nodes from RemnaWave: {e}')
|
||||
logger.warning('Failed to get nodes from RemnaWave', error=e)
|
||||
# Return empty data if RemnaWave is unavailable
|
||||
return NodesOverview(
|
||||
total=0,
|
||||
@@ -513,7 +560,9 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
)
|
||||
purchased_month = month_result.scalar() or 0
|
||||
|
||||
logger.info(f"📊 Тариф '{tariff.name}': активных={active_count}, триал={trial_count}")
|
||||
logger.info(
|
||||
'📊 Тариф активных=, триал', tariff_name=tariff.name, active_count=active_count, trial_count=trial_count
|
||||
)
|
||||
|
||||
tariff_items.append(
|
||||
TariffStatItem(
|
||||
@@ -529,7 +578,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
|
||||
total_tariff_subscriptions += active_count
|
||||
|
||||
logger.info(f'📊 Всего подписок по тарифам: {total_tariff_subscriptions}')
|
||||
logger.info('📊 Всего подписок по тарифам', total_tariff_subscriptions=total_tariff_subscriptions)
|
||||
|
||||
return TariffStats(
|
||||
tariffs=tariff_items,
|
||||
@@ -537,7 +586,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get tariff stats: {e}', exc_info=True)
|
||||
logger.error('Failed to get tariff stats', error=e, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -753,7 +802,7 @@ async def get_top_referrers(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get top referrers: {e}', exc_info=True)
|
||||
logger.error('Failed to get top referrers', error=e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load referrers statistics',
|
||||
@@ -810,7 +859,7 @@ async def get_top_campaigns(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get top campaigns: {e}', exc_info=True)
|
||||
logger.error('Failed to get top campaigns', error=e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load campaigns statistics',
|
||||
@@ -949,7 +998,7 @@ async def get_recent_payments(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get recent payments: {e}', exc_info=True)
|
||||
logger.error('Failed to get recent payments', error=e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load recent payments',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Admin routes for managing tariffs in cabinet."""
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -14,6 +13,7 @@ from app.database.crud.tariff import (
|
||||
get_tariff_by_id,
|
||||
get_tariff_subscriptions_count,
|
||||
load_period_prices_from_db,
|
||||
reorder_tariffs,
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
@@ -29,6 +29,7 @@ from ..schemas.tariffs import (
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
TariffListResponse,
|
||||
TariffSortOrderRequest,
|
||||
TariffStatsResponse,
|
||||
TariffToggleResponse,
|
||||
TariffTrialResponse,
|
||||
@@ -36,7 +37,7 @@ from ..schemas.tariffs import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/tariffs', tags=['Cabinet Admin Tariffs'])
|
||||
|
||||
@@ -157,6 +158,21 @@ async def get_available_servers(
|
||||
]
|
||||
|
||||
|
||||
@router.put('/order')
|
||||
async def update_tariff_order(
|
||||
request: TariffSortOrderRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the display order of tariffs."""
|
||||
await reorder_tariffs(db, request.tariff_ids)
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin updated tariff order', admin_id=admin.id, tariff_ids=request.tariff_ids)
|
||||
|
||||
return {'message': 'Tariff order updated successfully'}
|
||||
|
||||
|
||||
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
|
||||
async def get_tariff(
|
||||
tariff_id: int,
|
||||
@@ -278,7 +294,7 @@ async def create_new_tariff(
|
||||
traffic_reset_mode=request.traffic_reset_mode,
|
||||
)
|
||||
|
||||
logger.info(f'Admin {admin.id} created tariff {tariff.id}: {tariff.name}')
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
@@ -371,9 +387,9 @@ async def update_existing_tariff(
|
||||
|
||||
# Update promo groups separately
|
||||
if request.promo_group_ids is not None:
|
||||
await set_tariff_promo_groups(db, tariff_id, request.promo_group_ids)
|
||||
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
|
||||
logger.info('Admin updated tariff', admin_id=admin.id, tariff_id=tariff_id)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
@@ -395,21 +411,20 @@ async def delete_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Check if tariff has subscriptions
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
if subs_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Cannot delete tariff with {subs_count} active subscriptions',
|
||||
)
|
||||
|
||||
await delete_tariff(db, tariff)
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}')
|
||||
logger.info(
|
||||
'Admin deleted tariff (affected subscriptions: )',
|
||||
admin_id=admin.id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
subs_count=subs_count,
|
||||
)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return {'message': 'Tariff deleted successfully'}
|
||||
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
|
||||
@@ -430,7 +445,7 @@ async def toggle_tariff(
|
||||
await update_tariff(db, tariff, is_active=new_status)
|
||||
|
||||
status_text = 'activated' if new_status else 'deactivated'
|
||||
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
|
||||
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
@@ -473,7 +488,7 @@ async def toggle_trial_tariff(
|
||||
await update_tariff(db, tariff, is_trial_available=new_status)
|
||||
|
||||
status_text = 'set as trial' if new_status else 'removed from trial'
|
||||
logger.info(f'Admin {admin.id} {status_text} tariff {tariff_id}')
|
||||
logger.info('Admin tariff', admin_id=admin.id, status_text=status_text, tariff_id=tariff_id)
|
||||
|
||||
return TariffTrialResponse(
|
||||
id=tariff_id,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc, func, select
|
||||
@@ -20,7 +20,7 @@ from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.tickets import TicketMessageResponse
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/tickets', tags=['Cabinet Admin Tickets'])
|
||||
|
||||
@@ -317,7 +317,7 @@ async def update_ticket_settings(
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated ticket settings in .env file')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to update .env file: {e}')
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
|
||||
return TicketSettingsResponse(
|
||||
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
|
||||
@@ -336,6 +336,7 @@ async def get_all_tickets(
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
|
||||
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
|
||||
user_id: int | None = Query(None, description='Filter by user ID'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
@@ -355,6 +356,10 @@ async def get_all_tickets(
|
||||
query = query.where(Ticket.priority == priority_filter)
|
||||
count_query = count_query.where(Ticket.priority == priority_filter)
|
||||
|
||||
if user_id:
|
||||
query = query.where(Ticket.user_id == user_id)
|
||||
count_query = count_query.where(Ticket.user_id == user_id)
|
||||
|
||||
# Get total count
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
@@ -468,11 +473,11 @@ async def reply_to_ticket(
|
||||
|
||||
await notify_user_about_ticket_reply(bot, ticket, request.message, db)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to notify user about ticket reply: {e}')
|
||||
logger.warning('Failed to notify user about ticket reply', error=e)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to send Telegram notification: {e}')
|
||||
logger.warning('Failed to send Telegram notification', error=e)
|
||||
|
||||
# Уведомить пользователя в кабинете
|
||||
try:
|
||||
@@ -483,7 +488,7 @@ async def reply_to_ticket(
|
||||
# Отправить WebSocket уведомление
|
||||
await notify_user_ticket_reply(ticket.user_id, ticket.id, (request.message or '')[:100])
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to create cabinet notification for admin reply: {e}')
|
||||
logger.warning('Failed to create cabinet notification for admin reply', error=e)
|
||||
|
||||
return _message_to_response(message)
|
||||
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
"""Admin routes for traffic usage statistics."""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
TrafficEnrichmentResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
UserTrafficEnrichment,
|
||||
UserTrafficItem,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
|
||||
|
||||
_ALLOWED_PERIODS = frozenset({1, 3, 7, 14, 30})
|
||||
_CONCURRENCY_LIMIT = 5 # Max parallel API calls to avoid rate limiting
|
||||
|
||||
# In-memory cache: {(start_str, end_str): (timestamp, aggregated_data, nodes_info)}
|
||||
_traffic_cache: dict[tuple[str, str], tuple[float, dict[str, dict[str, int]], list[TrafficNodeInfo]]] = {}
|
||||
_CACHE_TTL = 300 # 5 minutes
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
# Valid sort fields for the GET endpoint
|
||||
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
|
||||
_ENRICHMENT_SORT_FIELDS = frozenset({'connected', 'total_spent', 'sub_start', 'sub_end', 'last_node'})
|
||||
|
||||
|
||||
def _get_status(sub) -> str | None:
|
||||
"""Get subscription status via actual_status property."""
|
||||
return sub.actual_status
|
||||
|
||||
|
||||
def _validate_period(period: int) -> None:
|
||||
if period not in _ALLOWED_PERIODS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Period must be one of: {sorted(_ALLOWED_PERIODS)}',
|
||||
)
|
||||
|
||||
|
||||
async def _aggregate_traffic(
|
||||
start_str: str, end_str: str, user_uuids: list[str]
|
||||
) -> tuple[dict[str, dict[str, int]], list[TrafficNodeInfo]]:
|
||||
"""Aggregate per-user traffic across all nodes for a given date range.
|
||||
|
||||
Uses legacy per-node endpoint to fetch all users' traffic per node —
|
||||
O(nodes) API calls instead of O(users). The legacy endpoint returns
|
||||
{userUuid, nodeUuid, total} per entry (non-legacy only returns topUsers
|
||||
without userUuid).
|
||||
|
||||
Returns (user_traffic, nodes_info) where:
|
||||
user_traffic = {remnawave_uuid: {node_uuid: total_bytes, ...}}
|
||||
nodes_info = [TrafficNodeInfo, ...]
|
||||
"""
|
||||
cache_key = (start_str, end_str)
|
||||
|
||||
# Quick check without lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
# Acquire lock for the slow path
|
||||
async with _cache_lock:
|
||||
# Re-check after acquiring lock
|
||||
now = time.time()
|
||||
cached = _traffic_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL:
|
||||
return cached[1], cached[2]
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return {}, []
|
||||
|
||||
user_uuids_set = set(user_uuids)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
# Fetch per-node user stats — O(nodes) calls instead of O(users)
|
||||
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
|
||||
|
||||
async def fetch_node_users(node):
|
||||
async with semaphore:
|
||||
try:
|
||||
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
|
||||
return node.uuid, stats
|
||||
except Exception:
|
||||
logger.warning('Failed to get traffic for node', node_name=node.name, exc_info=True)
|
||||
return node.uuid, None
|
||||
|
||||
results = await asyncio.gather(*(fetch_node_users(n) for n in nodes))
|
||||
|
||||
nodes_info: list[TrafficNodeInfo] = [
|
||||
TrafficNodeInfo(node_uuid=node.uuid, node_name=node.name, country_code=node.country_code) for node in nodes
|
||||
]
|
||||
nodes_info.sort(key=lambda n: n.node_name)
|
||||
|
||||
# Legacy response: [{userUuid, username, nodeUuid, total, date}, ...]
|
||||
user_traffic: dict[str, dict[str, int]] = {}
|
||||
for node_uuid, entries in results:
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for entry in entries:
|
||||
uid = entry.get('userUuid', '')
|
||||
total = int(entry.get('total', 0))
|
||||
if uid and total > 0 and uid in user_uuids_set:
|
||||
user_traffic.setdefault(uid, {})[node_uuid] = user_traffic.get(uid, {}).get(node_uuid, 0) + total
|
||||
|
||||
_traffic_cache[cache_key] = (now, user_traffic, nodes_info)
|
||||
|
||||
# Evict expired entries to prevent unbounded growth
|
||||
expired = [k for k, (ts, _, _) in _traffic_cache.items() if (now - ts) >= _CACHE_TTL]
|
||||
for k in expired:
|
||||
del _traffic_cache[k]
|
||||
|
||||
return user_traffic, nodes_info
|
||||
|
||||
|
||||
def _compute_date_range(period_days: int) -> tuple[str, str]:
|
||||
"""Compute ISO date-time range from period days.
|
||||
|
||||
Truncates to 5-minute intervals for stable cache keys.
|
||||
"""
|
||||
end_dt = datetime.now(UTC).replace(second=0, microsecond=0)
|
||||
end_dt = end_dt.replace(minute=(end_dt.minute // 5) * 5)
|
||||
start_dt = end_dt - timedelta(days=period_days)
|
||||
return start_dt.strftime('%Y-%m-%dT%H:%M:%SZ'), end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
|
||||
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
|
||||
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
|
||||
stmt = (
|
||||
select(User)
|
||||
.where(User.remnawave_uuid.isnot(None))
|
||||
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
users = result.scalars().all()
|
||||
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
|
||||
|
||||
|
||||
def _build_traffic_items(
|
||||
user_traffic: dict[str, dict[str, int]],
|
||||
user_map: dict[str, User],
|
||||
nodes_info: list[TrafficNodeInfo],
|
||||
search: str = '',
|
||||
sort_by: str = 'total_bytes',
|
||||
sort_desc: bool = True,
|
||||
tariff_filter: set[str] | None = None,
|
||||
status_filter: set[str] | None = None,
|
||||
node_filter: set[str] | None = None,
|
||||
) -> list[UserTrafficItem]:
|
||||
"""Merge traffic data with user data, apply search/tariff/status/node filters, return sorted list."""
|
||||
items: list[UserTrafficItem] = []
|
||||
search_lower = search.lower().strip()
|
||||
|
||||
all_uuids = set(user_traffic.keys()) | set(user_map.keys())
|
||||
for uuid in all_uuids:
|
||||
user = user_map.get(uuid)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
traffic = user_traffic.get(uuid, {})
|
||||
|
||||
full_name = user.full_name
|
||||
username = user.username
|
||||
email = user.email
|
||||
|
||||
if search_lower:
|
||||
if (
|
||||
search_lower not in (full_name or '').lower()
|
||||
and search_lower not in (username or '').lower()
|
||||
and search_lower not in (email or '').lower()
|
||||
):
|
||||
continue
|
||||
|
||||
sub = user.subscription
|
||||
tariff_name = None
|
||||
subscription_status = None
|
||||
traffic_limit_gb = 0.0
|
||||
device_limit = 1
|
||||
|
||||
if sub:
|
||||
subscription_status = _get_status(sub)
|
||||
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
|
||||
device_limit = sub.device_limit or 1
|
||||
if sub.tariff:
|
||||
tariff_name = sub.tariff.name
|
||||
|
||||
if tariff_filter is not None:
|
||||
if (tariff_name or '') not in tariff_filter:
|
||||
continue
|
||||
|
||||
if status_filter is not None:
|
||||
if (subscription_status or '') not in status_filter:
|
||||
continue
|
||||
|
||||
# Apply node filter: keep only selected nodes, recalculate total
|
||||
if node_filter is not None:
|
||||
traffic = {k: v for k, v in traffic.items() if k in node_filter}
|
||||
|
||||
total_bytes = sum(traffic.values())
|
||||
|
||||
items.append(
|
||||
UserTrafficItem(
|
||||
user_id=user.id,
|
||||
telegram_id=user.telegram_id,
|
||||
username=username,
|
||||
email=email,
|
||||
full_name=full_name,
|
||||
tariff_name=tariff_name,
|
||||
subscription_status=subscription_status,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
device_limit=device_limit,
|
||||
node_traffic=traffic,
|
||||
total_bytes=total_bytes,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort by the requested field; node columns use 'node_<uuid>' prefix
|
||||
if sort_by.startswith('node_'):
|
||||
node_uuid = sort_by[5:]
|
||||
items.sort(key=lambda x: x.node_traffic.get(node_uuid, 0), reverse=sort_desc)
|
||||
elif sort_by in ('full_name', 'tariff_name'):
|
||||
items.sort(key=lambda x: (getattr(x, sort_by, None) or '').lower(), reverse=sort_desc)
|
||||
else:
|
||||
items.sort(key=lambda x: getattr(x, sort_by, 0) or 0, reverse=sort_desc)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
@router.get('', response_model=TrafficUsageResponse)
|
||||
async def get_traffic_usage(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
period: int = Query(30, ge=1, le=30),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: str = Query('', max_length=100),
|
||||
sort_by: str = Query('total_bytes', max_length=100),
|
||||
sort_desc: bool = Query(True),
|
||||
tariffs: str = Query('', max_length=500),
|
||||
statuses: str = Query('', max_length=500),
|
||||
nodes: str = Query('', max_length=2000),
|
||||
start_date: str = Query('', max_length=10),
|
||||
end_date: str = Query('', max_length=10),
|
||||
):
|
||||
"""Get paginated per-user traffic usage by node."""
|
||||
# Determine date range: custom dates or period-based
|
||||
if start_date.strip() and end_date.strip():
|
||||
try:
|
||||
start_dt = datetime.strptime(start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(end_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC, hour=23, minute=59, second=59)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
effective_period = (end_dt - start_dt).days or 1
|
||||
else:
|
||||
_validate_period(period)
|
||||
start_str, end_str = _compute_date_range(period)
|
||||
effective_period = period
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
|
||||
# Collect all available tariff names (before filtering)
|
||||
available_tariffs = sorted(
|
||||
{
|
||||
u.subscription.tariff.name
|
||||
for u in user_map.values()
|
||||
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
|
||||
}
|
||||
)
|
||||
|
||||
# Collect all available statuses (before filtering)
|
||||
available_statuses = sorted(
|
||||
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
|
||||
)
|
||||
|
||||
# Parse tariff filter
|
||||
tariff_filter: set[str] | None = None
|
||||
if tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in tariffs.split(',') if t.strip()}
|
||||
|
||||
# Parse status filter
|
||||
status_filter: set[str] | None = None
|
||||
if statuses.strip():
|
||||
status_filter = {s.strip() for s in statuses.split(',') if s.strip()}
|
||||
|
||||
# Parse node filter
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if nodes.strip():
|
||||
node_filter = {n.strip() for n in nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None # No valid nodes matched, treat as "all nodes"
|
||||
|
||||
# Validate sort_by: allow known fields + enrichment fields + 'node_<uuid>'
|
||||
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
|
||||
is_enrichment_sort = sort_by in _ENRICHMENT_SORT_FIELDS
|
||||
if sort_by not in _SORT_FIELDS and not is_node_sort and not is_enrichment_sort:
|
||||
sort_by = 'total_bytes'
|
||||
|
||||
# For enrichment sort, build items unsorted then sort by enrichment field
|
||||
effective_sort = 'total_bytes' if is_enrichment_sort else sort_by
|
||||
items = _build_traffic_items(
|
||||
user_traffic, user_map, nodes_info, search, effective_sort, sort_desc, tariff_filter, status_filter, node_filter
|
||||
)
|
||||
|
||||
if is_enrichment_sort:
|
||||
enrichment_data = await _build_enrichment(db, user_map)
|
||||
enr_key_map = {
|
||||
'connected': lambda e: e.devices_connected,
|
||||
'total_spent': lambda e: e.total_spent_kopeks,
|
||||
'sub_start': lambda e: e.subscription_start_date or '',
|
||||
'sub_end': lambda e: e.subscription_end_date or '',
|
||||
'last_node': lambda e: e.last_node_name or '',
|
||||
}
|
||||
key_fn = enr_key_map[sort_by]
|
||||
empty = UserTrafficEnrichment()
|
||||
items.sort(key=lambda x: key_fn(enrichment_data.get(x.user_id, empty)), reverse=sort_desc)
|
||||
|
||||
total = len(items)
|
||||
paginated = items[offset : offset + limit]
|
||||
|
||||
return TrafficUsageResponse(
|
||||
items=paginated,
|
||||
nodes=nodes_info,
|
||||
total=total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
period_days=effective_period,
|
||||
available_tariffs=available_tariffs,
|
||||
available_statuses=available_statuses,
|
||||
)
|
||||
|
||||
|
||||
# ============== Enrichment endpoint ==============
|
||||
|
||||
_enrichment_cache: dict[str, tuple[float, dict[int, UserTrafficEnrichment]]] = {}
|
||||
_ENRICHMENT_CACHE_TTL = 300 # 5 minutes
|
||||
_enrichment_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int, int]:
|
||||
"""Get total spent kopeks for multiple users in a single query."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
|
||||
)
|
||||
)
|
||||
.group_by(Transaction.user_id)
|
||||
)
|
||||
return {row[0]: int(row[1]) for row in result.all()}
|
||||
|
||||
|
||||
async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict[int, UserTrafficEnrichment]:
|
||||
"""Build enrichment data for all users: devices, spending, dates, last node."""
|
||||
uuid_to_user_id: dict[str, int] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uuid_to_user_id[uuid] = user.id
|
||||
|
||||
service = RemnaWaveService()
|
||||
devices_by_user: dict[int, int] = {}
|
||||
last_node_uuid_by_user: dict[int, str] = {}
|
||||
node_uuid_to_name: dict[str, str] = {}
|
||||
|
||||
if service.is_configured:
|
||||
async with service.get_api_client() as api:
|
||||
# 3 bulk calls: nodes + users (paginated) + devices
|
||||
try:
|
||||
nodes_list = await api.get_all_nodes()
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch nodes for enrichment', exc_info=True)
|
||||
nodes_list = []
|
||||
|
||||
for node in nodes_list:
|
||||
node_uuid_to_name[node.uuid] = node.name
|
||||
|
||||
# Fetch all panel users (paginated) for last connected node
|
||||
panel_users = []
|
||||
try:
|
||||
first_page = await api.get_all_users(start=0, size=500)
|
||||
panel_users.extend(first_page['users'])
|
||||
total_panel = first_page['total']
|
||||
|
||||
if total_panel > 500:
|
||||
remaining_tasks = [
|
||||
api.get_all_users(start=offset, size=500) for offset in range(500, total_panel, 500)
|
||||
]
|
||||
pages = await asyncio.gather(*remaining_tasks, return_exceptions=True)
|
||||
for page in pages:
|
||||
if isinstance(page, dict):
|
||||
panel_users.extend(page['users'])
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch panel users for enrichment', exc_info=True)
|
||||
|
||||
for pu in panel_users:
|
||||
uid = uuid_to_user_id.get(pu.uuid)
|
||||
if uid is None:
|
||||
continue
|
||||
if pu.user_traffic and pu.user_traffic.last_connected_node_uuid:
|
||||
last_node_uuid_by_user[uid] = pu.user_traffic.last_connected_node_uuid
|
||||
|
||||
# Bulk device fetch — single API call (paginated with start/size)
|
||||
try:
|
||||
devices_data = await api.get_all_hwid_devices()
|
||||
for device in devices_data.get('devices', []):
|
||||
user_uuid = device.get('userUuid', '')
|
||||
uid = uuid_to_user_id.get(user_uuid)
|
||||
if uid is not None:
|
||||
devices_by_user[uid] = devices_by_user.get(uid, 0) + 1
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch bulk devices for enrichment', exc_info=True)
|
||||
|
||||
# Bulk spending stats
|
||||
all_user_ids = [u.id for u in user_map.values()]
|
||||
spending_map = await _get_bulk_spending(db, all_user_ids)
|
||||
|
||||
# Build enrichment data
|
||||
enrichment: dict[int, UserTrafficEnrichment] = {}
|
||||
for uuid, user in user_map.items():
|
||||
uid = user.id
|
||||
sub = user.subscription
|
||||
|
||||
start_date = None
|
||||
end_date = None
|
||||
if sub:
|
||||
if sub.start_date:
|
||||
start_date = sub.start_date.isoformat()
|
||||
if sub.end_date:
|
||||
end_date = sub.end_date.isoformat()
|
||||
|
||||
last_node_name = None
|
||||
last_uuid = last_node_uuid_by_user.get(uid)
|
||||
if last_uuid:
|
||||
last_node_name = node_uuid_to_name.get(last_uuid)
|
||||
|
||||
enrichment[uid] = UserTrafficEnrichment(
|
||||
devices_connected=devices_by_user.get(uid, 0),
|
||||
total_spent_kopeks=spending_map.get(uid, 0),
|
||||
subscription_start_date=start_date,
|
||||
subscription_end_date=end_date,
|
||||
last_node_name=last_node_name,
|
||||
)
|
||||
|
||||
return enrichment
|
||||
|
||||
|
||||
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
|
||||
async def get_traffic_enrichment(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return enrichment data: device counts, spending, dates, last node."""
|
||||
cache_key = 'enrichment'
|
||||
now = time.time()
|
||||
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
async with _enrichment_lock:
|
||||
now = time.time()
|
||||
cached = _enrichment_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
|
||||
return TrafficEnrichmentResponse(data=cached[1])
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
_enrichment_cache[cache_key] = (now, enrichment)
|
||||
|
||||
# Evict expired
|
||||
expired = [k for k, (ts, _) in _enrichment_cache.items() if (now - ts) >= _ENRICHMENT_CACHE_TTL]
|
||||
for k in expired:
|
||||
del _enrichment_cache[k]
|
||||
|
||||
return TrafficEnrichmentResponse(data=enrichment)
|
||||
|
||||
|
||||
@router.post('/export-csv', response_model=ExportCsvResponse)
|
||||
async def export_traffic_csv(
|
||||
request: ExportCsvRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
|
||||
if not admin.telegram_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Admin has no Telegram ID configured',
|
||||
)
|
||||
|
||||
# Determine date range: custom dates or period-based
|
||||
if request.start_date and request.end_date:
|
||||
try:
|
||||
start_dt = datetime.strptime(request.start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
|
||||
end_dt = datetime.strptime(request.end_date.strip(), '%Y-%m-%d').replace(
|
||||
tzinfo=UTC, hour=23, minute=59, second=59
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
|
||||
|
||||
now = datetime.now(UTC)
|
||||
end_dt = min(end_dt, now)
|
||||
if start_dt > end_dt:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
|
||||
if (end_dt - start_dt).days > 31:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
|
||||
|
||||
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
period_label = f'{request.start_date}_{request.end_date}'
|
||||
else:
|
||||
_validate_period(request.period)
|
||||
start_str, end_str = _compute_date_range(request.period)
|
||||
period_label = f'{request.period}d'
|
||||
|
||||
user_map = await _load_user_map(db)
|
||||
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
|
||||
enrichment = await _build_enrichment(db, user_map)
|
||||
|
||||
# Parse filters
|
||||
tariff_filter: set[str] | None = None
|
||||
if request.tariffs and request.tariffs.strip():
|
||||
tariff_filter = {t.strip() for t in request.tariffs.split(',') if t.strip()}
|
||||
|
||||
status_filter: set[str] | None = None
|
||||
if request.statuses and request.statuses.strip():
|
||||
status_filter = {s.strip() for s in request.statuses.split(',') if s.strip()}
|
||||
|
||||
node_filter: set[str] | None = None
|
||||
all_node_uuids = {n.node_uuid for n in nodes_info}
|
||||
if request.nodes and request.nodes.strip():
|
||||
node_filter = {n.strip() for n in request.nodes.split(',') if n.strip()} & all_node_uuids
|
||||
if not node_filter:
|
||||
node_filter = None
|
||||
|
||||
items = _build_traffic_items(
|
||||
user_traffic,
|
||||
user_map,
|
||||
nodes_info,
|
||||
sort_by='total_bytes',
|
||||
sort_desc=True,
|
||||
tariff_filter=tariff_filter,
|
||||
status_filter=status_filter,
|
||||
node_filter=node_filter,
|
||||
)
|
||||
|
||||
# Determine which nodes to include in CSV columns
|
||||
csv_nodes = [n for n in nodes_info if n.node_uuid in node_filter] if node_filter else nodes_info
|
||||
|
||||
# Compute period days for risk calculation
|
||||
if request.start_date and request.end_date:
|
||||
period_days = max((end_dt - start_dt).days, 1)
|
||||
else:
|
||||
period_days = request.period
|
||||
|
||||
total_thr = request.total_threshold_gb or 0
|
||||
node_thr = request.node_threshold_gb or 0
|
||||
has_risk = total_thr > 0 or node_thr > 0
|
||||
|
||||
# Build CSV rows
|
||||
rows: list[dict] = []
|
||||
for item in items:
|
||||
row: dict = {
|
||||
'User ID': item.user_id,
|
||||
'Telegram ID': item.telegram_id or '',
|
||||
'Username': item.username or '',
|
||||
'Email': item.email or '',
|
||||
'Full Name': item.full_name,
|
||||
'Tariff': item.tariff_name or '',
|
||||
'Status': item.subscription_status or '',
|
||||
'Traffic Limit (GB)': item.traffic_limit_gb,
|
||||
'Device Limit': item.device_limit,
|
||||
}
|
||||
# Enrichment columns
|
||||
enr = enrichment.get(item.user_id)
|
||||
row['Connected Devices'] = enr.devices_connected if enr else 0
|
||||
row['Total Spent (RUB)'] = round(enr.total_spent_kopeks / 100, 2) if enr else 0
|
||||
row['Sub Start'] = enr.subscription_start_date or '' if enr else ''
|
||||
row['Sub End'] = enr.subscription_end_date or '' if enr else ''
|
||||
row['Last Node'] = enr.last_node_name or '' if enr else ''
|
||||
|
||||
for node in csv_nodes:
|
||||
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
|
||||
row['Total (bytes)'] = item.total_bytes
|
||||
row['Total (GB)'] = round(item.total_bytes / (1024**3), 2) if item.total_bytes else 0
|
||||
|
||||
if has_risk:
|
||||
daily_total = item.total_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
row['Total GB/day'] = round(daily_total, 4)
|
||||
|
||||
total_ratio = daily_total / total_thr if total_thr > 0 else 0
|
||||
|
||||
max_node_ratio = 0.0
|
||||
worst_node_daily = 0.0
|
||||
for node_bytes in item.node_traffic.values():
|
||||
if node_bytes > 0 and node_thr > 0:
|
||||
daily_node = node_bytes / period_days / (1024**3) if period_days > 0 else 0
|
||||
ratio = daily_node / node_thr
|
||||
if ratio > max_node_ratio:
|
||||
max_node_ratio = ratio
|
||||
worst_node_daily = daily_node
|
||||
|
||||
ratio = max(total_ratio, max_node_ratio)
|
||||
if ratio < 0.5:
|
||||
risk_level = 'low'
|
||||
elif ratio < 0.8:
|
||||
risk_level = 'medium'
|
||||
elif ratio < 1.2:
|
||||
risk_level = 'high'
|
||||
else:
|
||||
risk_level = 'critical'
|
||||
|
||||
row['Risk Level'] = risk_level
|
||||
row['Risk Ratio'] = round(ratio, 3)
|
||||
row['Risk GB/day'] = round(daily_total if total_ratio >= max_node_ratio else worst_node_daily, 4)
|
||||
|
||||
rows.append(row)
|
||||
|
||||
# Generate CSV
|
||||
output = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
csv_bytes = output.getvalue().encode('utf-8-sig')
|
||||
|
||||
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
|
||||
|
||||
try:
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
async with bot:
|
||||
await bot.send_document(
|
||||
chat_id=admin.telegram_id,
|
||||
document=BufferedInputFile(csv_bytes, filename=filename),
|
||||
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
|
||||
)
|
||||
except Exception:
|
||||
logger.error('Failed to send CSV to admin', telegram_id=admin.telegram_id, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to send CSV report. Please try again later.',
|
||||
)
|
||||
|
||||
return ExportCsvResponse(success=True, message=f'CSV sent ({len(rows)} users)')
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Admin routes for version and release information."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database.models import User
|
||||
from app.services.version_service import version_service
|
||||
|
||||
from ..dependencies import get_current_admin_user
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class ReleaseItem(BaseModel):
|
||||
tag_name: str
|
||||
name: str
|
||||
body: str
|
||||
published_at: str
|
||||
prerelease: bool
|
||||
|
||||
|
||||
class ProjectReleasesInfo(BaseModel):
|
||||
current_version: str
|
||||
has_updates: bool
|
||||
releases: list[ReleaseItem]
|
||||
repo_url: str
|
||||
|
||||
|
||||
class ReleasesResponse(BaseModel):
|
||||
bot: ProjectReleasesInfo
|
||||
cabinet: ProjectReleasesInfo
|
||||
|
||||
|
||||
# ============ Cabinet releases cache ============
|
||||
|
||||
CABINET_REPO = 'BEDOLAGA-DEV/bedolaga-cabinet'
|
||||
_cabinet_cache: dict = {}
|
||||
_cabinet_last_check: datetime | None = None
|
||||
_CACHE_TTL = 3600
|
||||
|
||||
|
||||
async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
|
||||
global _cabinet_last_check
|
||||
|
||||
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
|
||||
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
|
||||
return _cabinet_cache['releases']
|
||||
|
||||
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
|
||||
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
releases = []
|
||||
for item in data[:20]:
|
||||
releases.append(
|
||||
{
|
||||
'tag_name': item['tag_name'],
|
||||
'name': item.get('name') or item['tag_name'],
|
||||
'body': item.get('body') or '',
|
||||
'published_at': item['published_at'],
|
||||
'prerelease': item.get('prerelease', False),
|
||||
}
|
||||
)
|
||||
_cabinet_cache['releases'] = releases
|
||||
_cabinet_last_check = datetime.now()
|
||||
logger.info('Fetched cabinet releases from GitHub', releases_count=len(releases))
|
||||
return releases
|
||||
logger.warning('GitHub API returned status for cabinet releases', response_status=response.status)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except TimeoutError:
|
||||
logger.warning('Timeout fetching cabinet releases from GitHub')
|
||||
return _cabinet_cache.get('releases', [])
|
||||
except Exception as e:
|
||||
logger.error('Error fetching cabinet releases', e=e)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/releases', response_model=ReleasesResponse)
|
||||
async def get_releases(
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
) -> ReleasesResponse:
|
||||
"""Get release information for bot and cabinet."""
|
||||
# Bot releases
|
||||
bot_releases_raw = await version_service._fetch_releases()
|
||||
has_updates, _ = await version_service.check_for_updates()
|
||||
|
||||
bot_releases = [
|
||||
ReleaseItem(
|
||||
tag_name=r.tag_name,
|
||||
name=r.name,
|
||||
body=r.full_description,
|
||||
published_at=r.published_at.isoformat(),
|
||||
prerelease=r.prerelease,
|
||||
)
|
||||
for r in bot_releases_raw[:10]
|
||||
]
|
||||
|
||||
bot_info = ProjectReleasesInfo(
|
||||
current_version=version_service.current_version,
|
||||
has_updates=has_updates,
|
||||
releases=bot_releases,
|
||||
repo_url=f'https://github.com/{version_service.repo}',
|
||||
)
|
||||
|
||||
# Cabinet releases
|
||||
cabinet_releases_raw = await _fetch_cabinet_releases()
|
||||
cabinet_releases = [ReleaseItem(**r) for r in cabinet_releases_raw[:10]]
|
||||
|
||||
# Current version = latest non-prerelease tag
|
||||
cabinet_current = ''
|
||||
for r in cabinet_releases_raw:
|
||||
if not r.get('prerelease', False):
|
||||
cabinet_current = r['tag_name']
|
||||
break
|
||||
|
||||
cabinet_info = ProjectReleasesInfo(
|
||||
current_version=cabinet_current,
|
||||
has_updates=False,
|
||||
releases=cabinet_releases,
|
||||
repo_url=f'https://github.com/{CABINET_REPO}',
|
||||
)
|
||||
|
||||
return ReleasesResponse(bot=bot_info, cabinet=cabinet_info)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,10 @@
|
||||
API роуты колеса удачи для администраторов.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -35,7 +35,7 @@ from app.database.models import User
|
||||
from app.services.wheel_service import wheel_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/wheel', tags=['Admin Fortune Wheel'])
|
||||
|
||||
@@ -107,7 +107,7 @@ async def update_admin_wheel_config(
|
||||
|
||||
config = await update_wheel_config(db, **update_data)
|
||||
|
||||
logger.info(f'🎡 Admin {admin.telegram_id} updated wheel config: {update_data}')
|
||||
logger.info('🎡 Admin updated wheel config', telegram_id=admin.telegram_id, update_data=update_data)
|
||||
|
||||
# Возвращаем полную конфигурацию
|
||||
prizes = await get_wheel_prizes(db, config.id, active_only=False)
|
||||
@@ -211,7 +211,7 @@ async def create_prize(
|
||||
promo_traffic_gb=request.promo_traffic_gb,
|
||||
)
|
||||
|
||||
logger.info(f'🎁 Admin {admin.telegram_id} created prize: {prize.display_name}')
|
||||
logger.info('🎁 Admin created prize', telegram_id=admin.telegram_id, display_name=prize.display_name)
|
||||
|
||||
return WheelPrizeAdminResponse(
|
||||
id=prize.id,
|
||||
@@ -261,7 +261,7 @@ async def update_prize(
|
||||
detail='Prize not found',
|
||||
)
|
||||
|
||||
logger.info(f'🎁 Admin {admin.telegram_id} updated prize {prize_id}: {update_data}')
|
||||
logger.info('🎁 Admin updated prize', telegram_id=admin.telegram_id, prize_id=prize_id, update_data=update_data)
|
||||
|
||||
return WheelPrizeAdminResponse(
|
||||
id=prize.id,
|
||||
@@ -298,7 +298,7 @@ async def delete_prize_endpoint(
|
||||
detail='Prize not found',
|
||||
)
|
||||
|
||||
logger.info(f'🗑️ Admin {admin.telegram_id} deleted prize {prize_id}')
|
||||
logger.info('🗑️ Admin deleted prize', telegram_id=admin.telegram_id, prize_id=prize_id)
|
||||
|
||||
|
||||
@router.post('/prizes/reorder', status_code=status.HTTP_200_OK)
|
||||
@@ -309,7 +309,7 @@ async def reorder_prizes(
|
||||
):
|
||||
"""Переупорядочить призы."""
|
||||
await reorder_wheel_prizes(db, request.prize_ids)
|
||||
logger.info(f'🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}')
|
||||
logger.info('🔄 Admin reordered prizes', telegram_id=admin.telegram_id, prize_ids=request.prize_ids)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
|
||||
+132
-25
@@ -2,11 +2,12 @@
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -22,6 +23,7 @@ from app.database.crud.user import (
|
||||
verify_and_apply_email_change,
|
||||
)
|
||||
from app.database.models import CabinetRefreshToken, User
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
|
||||
@@ -67,7 +69,7 @@ from ..services.email_service import email_service
|
||||
from ..services.email_template_overrides import get_rendered_override
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/auth', tags=['Cabinet Auth'])
|
||||
|
||||
@@ -156,12 +158,12 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
panel_users = await api.get_user_by_email(user.email)
|
||||
|
||||
if not panel_users:
|
||||
logger.debug(f'No subscription found in panel for email: {user.email}')
|
||||
logger.debug('No subscription found in panel for email', email=user.email)
|
||||
return
|
||||
|
||||
# Take first user if multiple found
|
||||
panel_user = panel_users[0]
|
||||
logger.info(f'Found subscription in panel for email {user.email}: {panel_user.uuid}')
|
||||
logger.info('Found subscription in panel for email', email=user.email, uuid=panel_user.uuid)
|
||||
|
||||
# Link user to panel
|
||||
user.remnawave_uuid = panel_user.uuid
|
||||
@@ -206,7 +208,10 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
existing_sub.device_limit = device_limit
|
||||
existing_sub.is_trial = False # Panel subscription is not trial
|
||||
logger.info(
|
||||
f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
|
||||
'Updated subscription for email user squads: devices',
|
||||
email=user.email,
|
||||
connected_squads=connected_squads,
|
||||
device_limit=device_limit,
|
||||
)
|
||||
else:
|
||||
# Create new subscription (expire_at and current_time already naive UTC)
|
||||
@@ -226,13 +231,16 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
)
|
||||
db.add(new_sub)
|
||||
logger.info(
|
||||
f'Created subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
|
||||
'Created subscription for email user squads: devices',
|
||||
email=user.email,
|
||||
connected_squads=connected_squads,
|
||||
device_limit=device_limit,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to sync subscription from panel for {user.email}: {e}')
|
||||
logger.warning('Failed to sync subscription from panel for', email=user.email, error=e)
|
||||
# Don't rollback - it detaches user object and breaks subsequent operations
|
||||
# The sync is non-critical, main verification already succeeded
|
||||
|
||||
@@ -273,7 +281,7 @@ async def auth_telegram(
|
||||
|
||||
if not user:
|
||||
# Create new user from Telegram initData
|
||||
logger.info(f'Creating new user from cabinet (initData): telegram_id={telegram_id}')
|
||||
logger.info('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=telegram_id,
|
||||
@@ -282,7 +290,7 @@ async def auth_telegram(
|
||||
last_name=tg_last_name,
|
||||
language=tg_language,
|
||||
)
|
||||
logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}')
|
||||
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
|
||||
else:
|
||||
# Update user info from initData (like bot middleware does)
|
||||
updated = False
|
||||
@@ -296,7 +304,7 @@ async def auth_telegram(
|
||||
user.last_name = tg_last_name
|
||||
updated = True
|
||||
if updated:
|
||||
logger.info(f'User {user.id} profile updated from initData')
|
||||
logger.info('User profile updated from initData', user_id=user.id)
|
||||
|
||||
if user.status != 'active':
|
||||
raise HTTPException(
|
||||
@@ -339,7 +347,9 @@ async def auth_telegram_widget(
|
||||
|
||||
if not user:
|
||||
# Create new user from Telegram data
|
||||
logger.info(f'Creating new user from cabinet: telegram_id={request.id}, username={request.username}')
|
||||
logger.info(
|
||||
'Creating new user from cabinet: telegram_id=, username', request_id=request.id, username=request.username
|
||||
)
|
||||
user = await create_user(
|
||||
db=db,
|
||||
telegram_id=request.id,
|
||||
@@ -348,7 +358,7 @@ async def auth_telegram_widget(
|
||||
last_name=request.last_name,
|
||||
language='ru',
|
||||
)
|
||||
logger.info(f'User created successfully: id={user.id}, telegram_id={user.telegram_id}')
|
||||
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
if user.status != 'active':
|
||||
raise HTTPException(
|
||||
@@ -385,6 +395,13 @@ async def register_email(
|
||||
Requires valid JWT token from Telegram authentication.
|
||||
Sends verification email to the provided address.
|
||||
"""
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_user = await db.execute(select(User).where(User.email == request.email))
|
||||
if existing_user.scalar_one_or_none():
|
||||
@@ -476,7 +493,14 @@ async def register_email_standalone(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid test email password',
|
||||
)
|
||||
logger.info(f'Test email registration: {request.email}')
|
||||
logger.info('Test email registration', email=request.email)
|
||||
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Проверить что email не занят
|
||||
existing = await db.execute(select(User).where(User.email == request.email))
|
||||
@@ -496,11 +520,17 @@ async def register_email_standalone(
|
||||
if referrer:
|
||||
# Защита от самореферала - нельзя регистрироваться по своему же коду
|
||||
if referrer.email and referrer.email.lower() == request.email.lower():
|
||||
logger.warning(f'Self-referral attempt blocked: email={request.email}, code={request.referral_code}')
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked: email=, code',
|
||||
email=request.email,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
referrer = None
|
||||
else:
|
||||
logger.info(
|
||||
f'Found referrer for email registration: referrer_id={referrer.id}, code={request.referral_code}'
|
||||
'Found referrer for email registration: referrer_id=, code',
|
||||
referrer_id=referrer.id,
|
||||
referral_code=request.referral_code,
|
||||
)
|
||||
|
||||
# Создать пользователя
|
||||
@@ -518,7 +548,7 @@ async def register_email_standalone(
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
logger.info(f'Test email auto-verified: {request.email}, user_id={user.id}')
|
||||
logger.info('Test email auto-verified: user_id', email=request.email, user_id=user.id)
|
||||
else:
|
||||
# Сгенерировать токен верификации
|
||||
verification_token = generate_verification_token()
|
||||
@@ -563,9 +593,11 @@ async def register_email_standalone(
|
||||
if referrer:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=None)
|
||||
logger.info(f'Processed referral registration: user_id={user.id}, referrer_id={referrer.id}')
|
||||
logger.info(
|
||||
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to process referral registration: {e}')
|
||||
logger.error('Failed to process referral registration', error=e)
|
||||
# Не прерываем регистрацию из-за ошибки реферальной системы
|
||||
|
||||
# Для тестового email - сразу можно логиниться (уже verified)
|
||||
@@ -708,7 +740,7 @@ async def login_email(
|
||||
if not user:
|
||||
# For test email - auto-create user if not exists
|
||||
if is_test_email and settings.validate_test_email_password(request.email, request.password):
|
||||
logger.info(f'Test email login - creating new user: {request.email}')
|
||||
logger.info('Test email login creating new user', email=request.email)
|
||||
password_hash = hash_password(request.password)
|
||||
user = await create_user_by_email(
|
||||
db=db,
|
||||
@@ -954,14 +986,13 @@ async def request_email_change(
|
||||
"""
|
||||
Request email change.
|
||||
|
||||
Sends a 6-digit verification code to the new email address.
|
||||
User must have a verified email to change it.
|
||||
For verified emails: sends a 6-digit verification code to the new email.
|
||||
For unverified emails: replaces the email directly and sends verification to the new address.
|
||||
"""
|
||||
# Check if user has a verified email
|
||||
if not user.email or not user.email_verified:
|
||||
if not user.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='You must have a verified email to change it',
|
||||
detail='No email address to change',
|
||||
)
|
||||
|
||||
# Check if new email is the same as current
|
||||
@@ -971,6 +1002,13 @@ async def request_email_change(
|
||||
detail='New email is the same as current email',
|
||||
)
|
||||
|
||||
# Check for disposable email
|
||||
if disposable_email_service.is_disposable(request.new_email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Disposable email addresses are not allowed',
|
||||
)
|
||||
|
||||
# Check if new email is already taken
|
||||
if await is_email_taken(db, request.new_email, exclude_user_id=user.id):
|
||||
raise HTTPException(
|
||||
@@ -978,6 +1016,75 @@ async def request_email_change(
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
# Unverified email: replace directly and send verification to new address
|
||||
if not user.email_verified:
|
||||
old_email = user.email
|
||||
user.email = request.new_email.lower()
|
||||
user.email_verified = False
|
||||
|
||||
verification_token = generate_verification_token()
|
||||
verification_expires = get_verification_expires_at()
|
||||
user.email_verification_token = verification_token
|
||||
user.email_verification_expires = verification_expires
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This email is already registered',
|
||||
)
|
||||
|
||||
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
|
||||
cabinet_url = settings.CABINET_URL
|
||||
verification_url = f'{cabinet_url}/verify-email'
|
||||
lang = user.language or 'ru'
|
||||
full_url = f'{verification_url}?token={verification_token}'
|
||||
expire_hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
|
||||
override = await get_rendered_override(
|
||||
'email_verification',
|
||||
lang,
|
||||
context={
|
||||
'username': user.first_name or '',
|
||||
'verification_url': full_url,
|
||||
'expire_hours': str(expire_hours),
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
custom_subject, custom_body = override if override else (None, None)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
email_service.send_verification_email,
|
||||
to_email=request.new_email,
|
||||
verification_token=verification_token,
|
||||
verification_url=verification_url,
|
||||
username=user.first_name,
|
||||
language=lang,
|
||||
custom_subject=custom_subject,
|
||||
custom_body_html=custom_body,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Failed to send verification email to for user',
|
||||
new_email=request.new_email,
|
||||
user_id=user.id,
|
||||
error=e,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Unverified email replaced for user', user_id=user.id, old_email=old_email, new_email=request.new_email
|
||||
)
|
||||
|
||||
return EmailChangeResponse(
|
||||
message='Email replaced, verification sent to new address',
|
||||
new_email=request.new_email,
|
||||
expires_in_minutes=0,
|
||||
)
|
||||
|
||||
# Verified email: send code to new address for confirmation
|
||||
# Generate verification code
|
||||
code = generate_email_change_code()
|
||||
expires_at = get_email_change_expires_at()
|
||||
@@ -1020,7 +1127,7 @@ async def request_email_change(
|
||||
detail='Email service is not configured',
|
||||
)
|
||||
|
||||
logger.info(f'Email change requested for user {user.id}: {user.email} -> {request.new_email}')
|
||||
logger.info('Email change requested for user', user_id=user.id, email=user.email, new_email=request.new_email)
|
||||
|
||||
return EmailChangeResponse(
|
||||
message='Verification code sent to new email',
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Balance and payment routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -12,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PaymentMethod, Transaction, User
|
||||
from app.external.cryptobot import CryptoBotService
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
from app.services.payment_service import PaymentService
|
||||
from app.services.payment_verification_service import (
|
||||
@@ -23,7 +23,7 @@ from app.services.payment_verification_service import (
|
||||
method_display_name,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.services.yookassa_service import YooKassaService
|
||||
from app.utils.currency_converter import currency_converter
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.balance import (
|
||||
@@ -41,7 +41,7 @@ from ..schemas.balance import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/balance', tags=['Cabinet Balance'])
|
||||
|
||||
@@ -249,7 +249,7 @@ async def create_stars_invoice(
|
||||
if stars_amount <= 0:
|
||||
stars_amount = 1
|
||||
except Exception as e:
|
||||
logger.error(f'Error calculating Stars amount: {e}')
|
||||
logger.error('Error calculating Stars amount', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to calculate Stars amount',
|
||||
@@ -279,7 +279,7 @@ async def create_stars_invoice(
|
||||
result = response.json()
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error(f'Telegram API error: {result}')
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Stars invoice',
|
||||
@@ -287,8 +287,10 @@ async def create_stars_invoice(
|
||||
|
||||
invoice_url = result['result']
|
||||
logger.info(
|
||||
f'Created Stars invoice for balance top-up: user={user.id}, '
|
||||
f'amount={request.amount_kopeks} kopeks, stars={stars_amount}'
|
||||
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
@@ -298,7 +300,7 @@ async def create_stars_invoice(
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f'HTTP error creating Stars invoice: {e}')
|
||||
logger.error('HTTP error creating Stars invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to connect to Telegram API',
|
||||
@@ -341,13 +343,11 @@ async def create_topup(
|
||||
|
||||
try:
|
||||
if request.payment_method == 'yookassa':
|
||||
yookassa_service = YooKassaService()
|
||||
payment_service = PaymentService()
|
||||
yookassa_metadata = {
|
||||
'user_id': str(user.id),
|
||||
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
|
||||
'user_username': user.username or '',
|
||||
'amount_kopeks': str(request.amount_kopeks),
|
||||
'type': 'balance_topup',
|
||||
'purpose': 'balance_topup',
|
||||
'source': 'cabinet',
|
||||
}
|
||||
|
||||
@@ -358,25 +358,25 @@ async def create_topup(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
)
|
||||
if option == 'sbp':
|
||||
# Create SBP payment with QR code
|
||||
result = await yookassa_service.create_sbp_payment(
|
||||
amount=amount_rubles,
|
||||
currency='RUB',
|
||||
result = await payment_service.create_yookassa_sbp_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
)
|
||||
else:
|
||||
# Default: card payment
|
||||
result = await yookassa_service.create_payment(
|
||||
amount=amount_rubles,
|
||||
currency='RUB',
|
||||
result = await payment_service.create_yookassa_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
)
|
||||
|
||||
if result and not result.get('error'):
|
||||
if result:
|
||||
payment_url = result.get('confirmation_url')
|
||||
payment_id = result.get('id')
|
||||
payment_id = result.get('yookassa_payment_id')
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -384,24 +384,49 @@ async def create_topup(
|
||||
)
|
||||
|
||||
elif request.payment_method == 'cryptobot':
|
||||
cryptobot_service = CryptoBotService()
|
||||
# Convert RUB to USDT (approximate)
|
||||
usdt_amount = amount_rubles / 100 # Approximate rate
|
||||
result = await cryptobot_service.create_invoice(
|
||||
amount=usdt_amount,
|
||||
asset='USDT',
|
||||
description=f'Balance top-up {amount_rubles:.2f} RUB',
|
||||
if not settings.is_cryptobot_enabled():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='CryptoBot payment method is unavailable',
|
||||
)
|
||||
|
||||
try:
|
||||
rate = await currency_converter.get_usd_to_rub_rate()
|
||||
except Exception:
|
||||
rate = 0.0
|
||||
if not rate or rate <= 0:
|
||||
rate = 95.0
|
||||
|
||||
try:
|
||||
amount_usd = float(
|
||||
(Decimal(request.amount_kopeks) / Decimal(100) / Decimal(str(rate))).quantize(
|
||||
Decimal('0.01'), rounding=ROUND_HALF_UP
|
||||
)
|
||||
)
|
||||
except (InvalidOperation, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Unable to convert amount to USD',
|
||||
)
|
||||
|
||||
payment_service = PaymentService()
|
||||
result = await payment_service.create_cryptobot_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
amount_usd=amount_usd,
|
||||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||||
description=settings.get_balance_payment_description(
|
||||
request.amount_kopeks, telegram_user_id=user.telegram_id
|
||||
),
|
||||
payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}',
|
||||
)
|
||||
if result:
|
||||
# Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback
|
||||
payment_url = (
|
||||
result.get('web_app_invoice_url')
|
||||
result.get('bot_invoice_url')
|
||||
or result.get('mini_app_invoice_url')
|
||||
or result.get('bot_invoice_url')
|
||||
or result.get('pay_url')
|
||||
or result.get('web_app_invoice_url')
|
||||
)
|
||||
payment_id = str(result.get('invoice_id'))
|
||||
payment_id = result.get('invoice_id') or str(result.get('local_payment_id', 'pending'))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -687,7 +712,7 @@ async def create_topup(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Payment creation error: {e}')
|
||||
logger.error('Payment creation error', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create payment. Please try again later.',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Branding routes for cabinet - logo, project name, and theme colors management."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -17,7 +17,7 @@ from app.database.models import SystemSetting, User
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/branding', tags=['Branding'])
|
||||
|
||||
@@ -36,6 +36,7 @@ EMAIL_AUTH_ENABLED_KEY = 'CABINET_EMAIL_AUTH_ENABLED' # Stores "true" or "false
|
||||
YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeric string)
|
||||
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
|
||||
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
|
||||
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
|
||||
@@ -144,6 +145,18 @@ class EmailAuthEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class LiteModeEnabledResponse(BaseModel):
|
||||
"""Lite mode enabled setting."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class LiteModeEnabledUpdate(BaseModel):
|
||||
"""Request to update lite mode setting."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
@@ -294,7 +307,7 @@ async def update_branding_name(
|
||||
|
||||
await set_setting_value(db, BRANDING_NAME_KEY, name)
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} updated branding name to: {name}')
|
||||
logger.info('Admin updated branding name to', telegram_id=admin.telegram_id, name=name)
|
||||
|
||||
# Return updated branding
|
||||
custom_logo = has_custom_logo()
|
||||
@@ -355,7 +368,7 @@ async def upload_logo(
|
||||
# Mark that we have a custom logo
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'custom')
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} uploaded new logo: {logo_path}')
|
||||
logger.info('Admin uploaded new logo', telegram_id=admin.telegram_id, logo_path=logo_path)
|
||||
|
||||
# Get current name for response
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
@@ -385,7 +398,7 @@ async def delete_logo(
|
||||
# Update setting
|
||||
await set_setting_value(db, BRANDING_LOGO_KEY, 'default')
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} deleted custom logo')
|
||||
logger.info('Admin deleted custom logo', telegram_id=admin.telegram_id)
|
||||
|
||||
# Get current name for response
|
||||
name = await get_setting_value(db, BRANDING_NAME_KEY)
|
||||
@@ -473,7 +486,7 @@ async def update_theme_colors(
|
||||
# Save to database
|
||||
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(current_colors))
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} updated theme colors: {list(update_data.keys())}')
|
||||
logger.info('Admin updated theme colors', telegram_id=admin.telegram_id, value=list(update_data.keys()))
|
||||
|
||||
return ThemeColorsResponse(**current_colors)
|
||||
|
||||
@@ -487,7 +500,7 @@ async def reset_theme_colors(
|
||||
# Save default colors
|
||||
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS))
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} reset theme colors to defaults')
|
||||
logger.info('Admin reset theme colors to defaults', telegram_id=admin.telegram_id)
|
||||
|
||||
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
|
||||
|
||||
@@ -545,7 +558,7 @@ async def update_enabled_themes(
|
||||
# Save to database
|
||||
await set_setting_value(db, ENABLED_THEMES_KEY, json.dumps(current_themes))
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} updated enabled themes: {current_themes}')
|
||||
logger.info('Admin updated enabled themes', telegram_id=admin.telegram_id, current_themes=current_themes)
|
||||
|
||||
return EnabledThemesResponse(**current_themes)
|
||||
|
||||
@@ -580,7 +593,7 @@ async def update_animation_enabled(
|
||||
"""Update animation enabled setting. Admin only."""
|
||||
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set animation enabled: {payload.enabled}')
|
||||
logger.info('Admin set animation enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return AnimationEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -615,7 +628,7 @@ async def update_fullscreen_enabled(
|
||||
"""Update fullscreen enabled setting. Admin only."""
|
||||
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}')
|
||||
logger.info('Admin set fullscreen enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return FullscreenEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -651,7 +664,7 @@ async def update_email_auth_enabled(
|
||||
"""Update email auth enabled setting. Admin only."""
|
||||
await set_setting_value(db, EMAIL_AUTH_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set email auth enabled: {payload.enabled}')
|
||||
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return EmailAuthEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -706,7 +719,7 @@ async def update_analytics_counters(
|
||||
if payload.google_ads_label is not None:
|
||||
await set_setting_value(db, GOOGLE_ADS_LABEL_KEY, payload.google_ads_label.strip())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} updated analytics counters')
|
||||
logger.info('Admin updated analytics counters', telegram_id=admin.telegram_id)
|
||||
|
||||
# Return current state
|
||||
yandex_id = await get_setting_value(db, YANDEX_METRIKA_ID_KEY) or ''
|
||||
@@ -718,3 +731,39 @@ async def update_analytics_counters(
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
)
|
||||
|
||||
|
||||
# ============ Lite Mode Routes ============
|
||||
|
||||
|
||||
@router.get('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def get_lite_mode_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get lite mode enabled setting.
|
||||
This is a public endpoint - no authentication required.
|
||||
When enabled, shows simplified dashboard with minimal features.
|
||||
"""
|
||||
lite_mode_value = await get_setting_value(db, LITE_MODE_ENABLED_KEY)
|
||||
|
||||
if lite_mode_value is not None:
|
||||
enabled = lite_mode_value.lower() == 'true'
|
||||
return LiteModeEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: disabled
|
||||
return LiteModeEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def update_lite_mode_enabled(
|
||||
payload: LiteModeEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update lite mode enabled setting. Admin only."""
|
||||
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info('Admin set lite mode enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return LiteModeEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Contests routes for cabinet - user participation in games/contests."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -30,7 +30,7 @@ from app.services.contest_rotation_service import (
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/contests', tags=['Cabinet Contests'])
|
||||
|
||||
@@ -106,7 +106,7 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'🎁 Extended subscription for user {user_id} by {days} days (contest prize)')
|
||||
logger.info('🎁 Extended subscription for user by days (contest prize)', user_id=user_id, days=days)
|
||||
return f'Subscription extended by {days} days'
|
||||
|
||||
if prize_type == 'balance':
|
||||
@@ -125,10 +125,10 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f'🎁 Added {amount} to balance for user {user_id} (contest prize)')
|
||||
logger.info('🎁 Added to balance for user (contest prize)', amount=amount, user_id=user_id)
|
||||
return f'Balance increased by {amount}'
|
||||
|
||||
logger.warning(f'Unknown prize type: {prize_type}')
|
||||
logger.warning('Unknown prize type', prize_type=prize_type)
|
||||
return f"Prize type '{prize_type}' not supported"
|
||||
|
||||
|
||||
|
||||
+41
-12
@@ -1,7 +1,6 @@
|
||||
"""Info pages routes for cabinet - FAQ, rules, privacy policy, etc."""
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,10 +15,34 @@ from app.services.public_offer_service import PublicOfferService
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
|
||||
|
||||
_LANGUAGE_META: dict[str, tuple[str, str]] = {
|
||||
'ru': ('Русский', '🇷🇺'),
|
||||
'en': ('English', '🇬🇧'),
|
||||
'ua': ('Українська', '🇺🇦'),
|
||||
'zh': ('中文', '🇨🇳'),
|
||||
'fa': ('فارسی', '🇮🇷'),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_language_code(value: str | None) -> str:
|
||||
return (value or '').strip().lower().split('-', 1)[0]
|
||||
|
||||
|
||||
def _get_available_language_codes() -> list[str]:
|
||||
codes: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for code in settings.get_available_languages():
|
||||
normalized = _normalize_language_code(code)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
codes.append(normalized)
|
||||
return codes
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
@@ -212,12 +235,19 @@ async def get_service_info():
|
||||
@router.get('/languages')
|
||||
async def get_available_languages():
|
||||
"""Get list of available languages."""
|
||||
codes = _get_available_language_codes()
|
||||
default_language = _normalize_language_code(getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru')
|
||||
|
||||
return {
|
||||
'languages': [
|
||||
{'code': 'ru', 'name': 'Русский', 'flag': '🇷🇺'},
|
||||
{'code': 'en', 'name': 'English', 'flag': '🇬🇧'},
|
||||
{
|
||||
'code': code,
|
||||
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
|
||||
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
|
||||
}
|
||||
for code in codes
|
||||
],
|
||||
'default': getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
|
||||
'default': default_language,
|
||||
}
|
||||
|
||||
|
||||
@@ -236,16 +266,15 @@ async def update_user_language(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's language preference."""
|
||||
language = request.get('language', 'ru')
|
||||
|
||||
valid_languages = ['ru', 'en']
|
||||
if language not in valid_languages:
|
||||
requested_language = _normalize_language_code(request.get('language', 'ru'))
|
||||
available_languages = _get_available_language_codes()
|
||||
if requested_language not in available_languages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid language. Supported: {", ".join(valid_languages)}',
|
||||
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
|
||||
)
|
||||
|
||||
user.language = language
|
||||
user.language = requested_language
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Media upload/download routes for cabinet tickets."""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -16,7 +16,7 @@ from app.database.models import User
|
||||
from ..dependencies import get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/media', tags=['Cabinet Media'])
|
||||
|
||||
@@ -125,7 +125,12 @@ async def upload_media(
|
||||
|
||||
media_url = _build_media_url(request, media.file_id)
|
||||
|
||||
logger.info(f'User {user.telegram_id} uploaded {media_type_normalized}: {media.file_id}')
|
||||
logger.info(
|
||||
'User uploaded',
|
||||
telegram_id=user.telegram_id,
|
||||
media_type_normalized=media_type_normalized,
|
||||
file_id=media.file_id,
|
||||
)
|
||||
|
||||
return MediaUploadResponse(
|
||||
media_type=media_type_normalized,
|
||||
@@ -136,7 +141,7 @@ async def upload_media(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f'Failed to upload media for user {user.telegram_id}: {error}')
|
||||
logger.error('Failed to upload media for user', telegram_id=user.telegram_id, error=error)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to upload media',
|
||||
@@ -187,7 +192,7 @@ async def download_media(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f'Failed to download media {file_id}: {error}')
|
||||
logger.error('Failed to download media', file_id=file_id, error=error)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to download media',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Notification settings routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -13,7 +13,7 @@ from app.database.models import User
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/notifications', tags=['Cabinet Notifications'])
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""OAuth 2.0 authentication routes for cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.user import (
|
||||
create_user_by_oauth,
|
||||
get_user_by_email,
|
||||
get_user_by_oauth_provider,
|
||||
set_user_oauth_provider_id,
|
||||
)
|
||||
from app.database.models import User
|
||||
|
||||
from ..auth.oauth_providers import (
|
||||
OAuthUserInfo,
|
||||
generate_oauth_state,
|
||||
get_provider,
|
||||
validate_oauth_state,
|
||||
)
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.auth import AuthResponse
|
||||
from .auth import _create_auth_response, _store_refresh_token
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
|
||||
|
||||
|
||||
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
|
||||
await db.commit()
|
||||
auth_response = _create_auth_response(user)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
return auth_response
|
||||
|
||||
|
||||
# --- Schemas ---
|
||||
|
||||
|
||||
class OAuthProviderInfo(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class OAuthProvidersResponse(BaseModel):
|
||||
providers: list[OAuthProviderInfo]
|
||||
|
||||
|
||||
class OAuthAuthorizeResponse(BaseModel):
|
||||
authorize_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
code: str = Field(..., description='Authorization code from provider')
|
||||
state: str = Field(..., description='CSRF state token')
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
|
||||
@router.get('/providers', response_model=OAuthProvidersResponse)
|
||||
async def get_oauth_providers():
|
||||
"""Get list of enabled OAuth providers."""
|
||||
providers_config = settings.get_oauth_providers_config()
|
||||
providers = [
|
||||
OAuthProviderInfo(name=name, display_name=cfg['display_name'])
|
||||
for name, cfg in providers_config.items()
|
||||
if cfg['enabled']
|
||||
]
|
||||
return OAuthProvidersResponse(providers=providers)
|
||||
|
||||
|
||||
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
|
||||
async def get_oauth_authorize_url(provider: str):
|
||||
"""Get authorization URL for an OAuth provider."""
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
state = await generate_oauth_state(provider)
|
||||
authorize_url = oauth_provider.get_authorization_url(state)
|
||||
|
||||
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
|
||||
|
||||
|
||||
@router.post('/{provider}/callback', response_model=AuthResponse)
|
||||
async def oauth_callback(
|
||||
provider: str,
|
||||
request: OAuthCallbackRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
|
||||
# 1. Validate CSRF state
|
||||
if not await validate_oauth_state(request.state, provider):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid or expired OAuth state',
|
||||
)
|
||||
|
||||
# 2. Get provider instance
|
||||
oauth_provider = get_provider(provider)
|
||||
if not oauth_provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'OAuth provider "{provider}" is not enabled',
|
||||
)
|
||||
|
||||
# 3. Exchange code for tokens
|
||||
try:
|
||||
token_data = await oauth_provider.exchange_code(request.code)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth code exchange failed for', provider=provider, exc=exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to exchange authorization code',
|
||||
) from exc
|
||||
|
||||
# 4. Fetch user info from provider
|
||||
try:
|
||||
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth user info fetch failed for', provider=provider, exc=exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to fetch user information from provider',
|
||||
) from exc
|
||||
|
||||
# 5. Find user by provider ID
|
||||
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if user:
|
||||
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
# 6. Find user by email (if verified) and link provider
|
||||
if user_info.email and user_info.email_verified:
|
||||
user = await get_user_by_email(db, user_info.email)
|
||||
if user:
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
|
||||
# 7. Create new user
|
||||
user = await create_user_by_oauth(
|
||||
db=db,
|
||||
provider=provider,
|
||||
provider_id=user_info.provider_id,
|
||||
email=user_info.email if user_info.email_verified else None,
|
||||
email_verified=user_info.email_verified,
|
||||
first_name=user_info.first_name,
|
||||
last_name=user_info.last_name,
|
||||
username=user_info.username,
|
||||
)
|
||||
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Polls routes for cabinet - user participation in polls/surveys."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
@@ -20,7 +20,7 @@ from app.services.poll_service import get_next_question, get_question_option, re
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/polls', tags=['Cabinet Polls'])
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Promo offers routes for cabinet - personal discounts and offers."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, select
|
||||
@@ -22,7 +22,7 @@ from app.services.promo_offer_service import promo_offer_service
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/promo', tags=['Cabinet Promo'])
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Promo code routes for cabinet."""
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -12,7 +11,7 @@ from app.services.promocode_service import PromoCodeService
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/promocode', tags=['Cabinet Promocode'])
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Referral program routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,7 +22,7 @@ from ..schemas.referral import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral', tags=['Cabinet Referral'])
|
||||
|
||||
|
||||
+270
-118
@@ -2,11 +2,11 @@
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -58,7 +58,7 @@ from ..schemas.subscription import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/subscription', tags=['Cabinet Subscription'])
|
||||
|
||||
@@ -490,9 +490,9 @@ async def renew_subscription(
|
||||
|
||||
try:
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Cart saved for auto-renewal (cabinet) user {user.id}')
|
||||
logger.info('Cart saved for auto-renewal (cabinet) user', user_id=user.id)
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving cart for auto-renewal (cabinet): {e}')
|
||||
logger.error('Error saving cart for auto-renewal (cabinet)', error=e)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
@@ -550,7 +550,7 @@ async def renew_subscription(
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send admin notification for subscription renewal: {e}')
|
||||
logger.error('Failed to send admin notification for subscription renewal', error=e)
|
||||
|
||||
response = {
|
||||
'message': 'Subscription renewed successfully',
|
||||
@@ -598,6 +598,8 @@ async def get_traffic_packages(
|
||||
result = []
|
||||
|
||||
for gb, price in packages.items():
|
||||
if price <= 0:
|
||||
continue
|
||||
result.append(
|
||||
TrafficPackageResponse(
|
||||
gb=gb,
|
||||
@@ -619,12 +621,14 @@ async def get_traffic_packages(
|
||||
if tariff and not tariff.allow_traffic_topup:
|
||||
return []
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
result = []
|
||||
|
||||
for pkg in packages:
|
||||
if not pkg.get('enabled', True):
|
||||
continue
|
||||
if pkg['price'] <= 0:
|
||||
continue
|
||||
|
||||
result.append(
|
||||
TrafficPackageResponse(
|
||||
@@ -705,6 +709,11 @@ async def purchase_traffic(
|
||||
detail=f'Traffic package {request.gb}GB is not available',
|
||||
)
|
||||
base_price_kopeks = packages[request.gb]
|
||||
if base_price_kopeks <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Traffic package {request.gb}GB has no price configured',
|
||||
)
|
||||
|
||||
else:
|
||||
# Classic режим
|
||||
@@ -724,7 +733,7 @@ async def purchase_traffic(
|
||||
)
|
||||
|
||||
# Получаем цену из глобальных настроек
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
@@ -732,6 +741,11 @@ async def purchase_traffic(
|
||||
detail='Invalid traffic package',
|
||||
)
|
||||
base_price_kopeks = matching_pkg['price']
|
||||
if base_price_kopeks <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Traffic package has no price configured',
|
||||
)
|
||||
|
||||
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
|
||||
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
|
||||
@@ -775,11 +789,13 @@ async def purchase_traffic(
|
||||
try:
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(
|
||||
f'Cart saved for traffic purchase (cabinet) user {user.id}: '
|
||||
f'+{request.gb} GB, discount {traffic_discount_percent}%'
|
||||
'Cart saved for traffic purchase (cabinet) user + discount',
|
||||
user_id=user.id,
|
||||
gb=request.gb,
|
||||
traffic_discount_percent=traffic_discount_percent,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving cart for traffic purchase (cabinet): {e}')
|
||||
logger.error('Error saving cart for traffic purchase (cabinet)', error=e)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
@@ -819,7 +835,11 @@ async def purchase_traffic(
|
||||
from datetime import timedelta
|
||||
|
||||
subscription.traffic_reset_at = datetime.utcnow() + timedelta(days=30)
|
||||
logger.info(f'Set traffic_reset_at for subscription {subscription.id}: {subscription.traffic_reset_at}')
|
||||
logger.info(
|
||||
'Set traffic_reset_at for subscription',
|
||||
subscription_id=subscription.id,
|
||||
traffic_reset_at=subscription.traffic_reset_at,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -831,7 +851,7 @@ async def purchase_traffic(
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to sync traffic with RemnaWave: {e}')
|
||||
logger.error('Failed to sync traffic with RemnaWave', error=e)
|
||||
|
||||
# Создаём транзакцию
|
||||
await create_transaction(
|
||||
@@ -868,7 +888,7 @@ async def purchase_traffic(
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send admin notification for traffic purchase: {e}')
|
||||
logger.error('Failed to send admin notification for traffic purchase', error=e)
|
||||
|
||||
response = {
|
||||
'success': True,
|
||||
@@ -932,9 +952,13 @@ async def purchase_devices_legacy(
|
||||
'source': 'cabinet',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Cart saved for device purchase (cabinet /devices) user {user.id}: +{request.devices} devices')
|
||||
logger.info(
|
||||
'Cart saved for device purchase (cabinet /devices) user + devices',
|
||||
user_id=user.id,
|
||||
devices=request.devices,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving cart for device purchase (cabinet /devices): {e}')
|
||||
logger.error('Error saving cart for device purchase (cabinet /devices)', error=e)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
@@ -1006,7 +1030,7 @@ async def purchase_devices_legacy(
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send admin notification for device purchase: {e}')
|
||||
logger.error('Failed to send admin notification for device purchase', error=e)
|
||||
|
||||
response = {
|
||||
'message': 'Devices added successfully',
|
||||
@@ -1070,6 +1094,19 @@ async def get_trial_info(
|
||||
"""Get trial subscription info and availability."""
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
# Проверяем, отключён ли триал для этого типа пользователя
|
||||
if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')):
|
||||
return TrialInfoResponse(
|
||||
is_available=False,
|
||||
duration_days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
device_limit=settings.TRIAL_DEVICE_LIMIT,
|
||||
requires_payment=bool(settings.TRIAL_PAYMENT_ENABLED),
|
||||
price_kopeks=0,
|
||||
price_rubles=0,
|
||||
reason_unavailable='Trial is not available for your account type',
|
||||
)
|
||||
|
||||
duration_days = settings.TRIAL_DURATION_DAYS
|
||||
traffic_limit_gb = settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
device_limit = settings.TRIAL_DEVICE_LIMIT
|
||||
@@ -1096,7 +1133,7 @@ async def get_trial_info(
|
||||
if tariff_trial_days:
|
||||
duration_days = tariff_trial_days
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting trial tariff for info: {e}')
|
||||
logger.error('Error getting trial tariff for info', error=e)
|
||||
|
||||
# Check if user already has an active subscription
|
||||
if user.subscription:
|
||||
@@ -1148,6 +1185,13 @@ async def activate_trial(
|
||||
"""Activate trial subscription."""
|
||||
await db.refresh(user, ['subscription'])
|
||||
|
||||
# Проверяем, отключён ли триал для этого типа пользователя
|
||||
if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Trial is not available for your account type',
|
||||
)
|
||||
|
||||
# Check if user already has an active subscription
|
||||
if user.subscription:
|
||||
now = datetime.utcnow()
|
||||
@@ -1177,7 +1221,7 @@ async def activate_trial(
|
||||
detail=f'Insufficient balance. Need {price_kopeks / 100:.2f} RUB',
|
||||
)
|
||||
user.balance_kopeks -= price_kopeks
|
||||
logger.info(f'User {user.id} paid {price_kopeks} kopeks for trial activation')
|
||||
logger.info('User paid kopeks for trial activation', user_id=user.id, price_kopeks=price_kopeks)
|
||||
|
||||
# Get trial parameters from tariff if configured (same logic as bot handler)
|
||||
trial_duration = settings.TRIAL_DURATION_DAYS
|
||||
@@ -1209,9 +1253,14 @@ async def activate_trial(
|
||||
tariff_trial_days = getattr(trial_tariff, 'trial_duration_days', None)
|
||||
if tariff_trial_days:
|
||||
trial_duration = tariff_trial_days
|
||||
logger.info(f'Using trial tariff {trial_tariff.name} (ID: {trial_tariff.id}) with squads: {trial_squads}')
|
||||
logger.info(
|
||||
'Using trial tariff (ID: ) with squads',
|
||||
trial_tariff_name=trial_tariff.name,
|
||||
trial_tariff_id=trial_tariff.id,
|
||||
trial_squads=trial_squads,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting trial tariff: {e}')
|
||||
logger.error('Error getting trial tariff', error=e)
|
||||
|
||||
# Create trial subscription
|
||||
subscription = await create_trial_subscription(
|
||||
@@ -1224,7 +1273,7 @@ async def activate_trial(
|
||||
tariff_id=tariff_id_for_trial,
|
||||
)
|
||||
|
||||
logger.info(f'Trial subscription activated for user {user.id}')
|
||||
logger.info('Trial subscription activated for user', user_id=user.id)
|
||||
|
||||
# Create RemnaWave user
|
||||
try:
|
||||
@@ -1233,7 +1282,7 @@ async def activate_trial(
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
await db.refresh(subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to create RemnaWave user for trial: {e}')
|
||||
logger.error('Failed to create RemnaWave user for trial', error=e)
|
||||
|
||||
# Send admin notification about trial activation
|
||||
try:
|
||||
@@ -1252,7 +1301,7 @@ async def activate_trial(
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send trial activation notification: {e}')
|
||||
logger.error('Failed to send trial activation notification', error=e)
|
||||
|
||||
return _subscription_to_response(subscription)
|
||||
|
||||
@@ -1517,7 +1566,7 @@ async def get_purchase_options(
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to build purchase options for user {user.id}: {e}')
|
||||
logger.error('Failed to build purchase options for user', user_id=user.id, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to load purchase options',
|
||||
@@ -1562,7 +1611,7 @@ async def preview_purchase(
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to calculate purchase preview for user {user.id}: {e}')
|
||||
logger.error('Failed to calculate purchase preview for user', user_id=user.id, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to calculate price',
|
||||
@@ -1624,7 +1673,7 @@ async def submit_purchase(
|
||||
bot=None,
|
||||
)
|
||||
except Exception as notif_error:
|
||||
logger.warning(f'Failed to send subscription notification to {user.email}: {notif_error}')
|
||||
logger.warning('Failed to send subscription notification to', email=user.email, notif_error=notif_error)
|
||||
|
||||
# Отправляем уведомление админам о покупке подписки
|
||||
try:
|
||||
@@ -1642,7 +1691,7 @@ async def submit_purchase(
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
transaction=None,
|
||||
period_days=selection.period_days,
|
||||
period_days=selection.period.days,
|
||||
was_trial_conversion=result.get('was_trial_conversion', False),
|
||||
amount_kopeks=pricing.final_total,
|
||||
purchase_type='renewal' if not is_new_subscription else None,
|
||||
@@ -1650,7 +1699,7 @@ async def submit_purchase(
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send admin notification for subscription purchase: {e}')
|
||||
logger.error('Failed to send admin notification for subscription purchase', error=e)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
@@ -1682,9 +1731,9 @@ async def submit_purchase(
|
||||
'source': 'cabinet',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Cart saved for auto-purchase (cabinet /purchase) user {user.id}')
|
||||
logger.info('Cart saved for auto-purchase (cabinet /purchase) user', user_id=user.id)
|
||||
except Exception as cart_error:
|
||||
logger.error(f'Error saving cart for auto-purchase (cabinet /purchase): {cart_error}')
|
||||
logger.error('Error saving cart for auto-purchase (cabinet /purchase)', cart_error=cart_error)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
@@ -1696,7 +1745,7 @@ async def submit_purchase(
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to submit purchase for user {user.id}: {e}')
|
||||
logger.error('Failed to submit purchase for user', user_id=user.id, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to process purchase',
|
||||
@@ -1878,9 +1927,9 @@ async def purchase_tariff(
|
||||
|
||||
try:
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Cart saved for auto-purchase (cabinet) user {user.id}, tariff {tariff.id}')
|
||||
logger.info('Cart saved for auto-purchase (cabinet) user tariff', user_id=user.id, tariff_id=tariff.id)
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving cart for auto-purchase (cabinet): {e}')
|
||||
logger.error('Error saving cart for auto-purchase (cabinet)', error=e)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
@@ -1985,7 +2034,7 @@ async def purchase_tariff(
|
||||
reset_reason='покупка тарифа (cabinet)',
|
||||
)
|
||||
except Exception as remnawave_error:
|
||||
logger.error(f'Failed to sync subscription with RemnaWave: {remnawave_error}')
|
||||
logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error)
|
||||
|
||||
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
|
||||
if not is_daily_tariff:
|
||||
@@ -1999,9 +2048,9 @@ async def purchase_tariff(
|
||||
'description': f'Продление тарифа {tariff.name} на {period_days} дней',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Tariff cart saved for auto-renewal (cabinet) user {user.id}')
|
||||
logger.info('Tariff cart saved for auto-renewal (cabinet) user', user_id=user.id)
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving tariff cart (cabinet): {e}')
|
||||
logger.error('Error saving tariff cart (cabinet)', error=e)
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -2061,7 +2110,7 @@ async def purchase_tariff(
|
||||
bot=None,
|
||||
)
|
||||
except Exception as notif_error:
|
||||
logger.warning(f'Failed to send subscription notification to {user.email}: {notif_error}')
|
||||
logger.warning('Failed to send subscription notification to', email=user.email, notif_error=notif_error)
|
||||
|
||||
# Отправляем уведомление админам о покупке/продлении тарифа
|
||||
try:
|
||||
@@ -2090,14 +2139,14 @@ async def purchase_tariff(
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send admin notification for tariff purchase: {e}')
|
||||
logger.error('Failed to send admin notification for tariff purchase', error=e)
|
||||
|
||||
return response
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to purchase tariff for user {user.id}: {e}')
|
||||
logger.error('Failed to purchase tariff for user', user_id=user.id, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to process tariff purchase',
|
||||
@@ -2204,11 +2253,13 @@ async def purchase_devices(
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(
|
||||
f'Cart saved for device purchase (cabinet) user {user.id}: '
|
||||
f'+{request.devices} devices, discount {devices_discount_percent}%'
|
||||
'Cart saved for device purchase (cabinet) user + devices, discount',
|
||||
user_id=user.id,
|
||||
devices=request.devices,
|
||||
devices_discount_percent=devices_discount_percent,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error saving cart for device purchase (cabinet): {e}')
|
||||
logger.error('Error saving cart for device purchase (cabinet)', error=e)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
@@ -2254,17 +2305,23 @@ async def purchase_devices(
|
||||
else:
|
||||
await service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to sync devices with RemnaWave: {e}')
|
||||
logger.error('Failed to sync devices with RemnaWave', error=e)
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if devices_discount_percent > 0:
|
||||
logger.info(
|
||||
f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks '
|
||||
f'(discount {devices_discount_percent}%, saved {discount_value} kopeks)'
|
||||
'User purchased devices for kopeks (discount saved kopeks)',
|
||||
user_id=user.id,
|
||||
devices=request.devices,
|
||||
price_kopeks=price_kopeks,
|
||||
devices_discount_percent=devices_discount_percent,
|
||||
discount_value=discount_value,
|
||||
)
|
||||
else:
|
||||
logger.info(f'User {user.id} purchased {request.devices} devices for {price_kopeks} kopeks')
|
||||
logger.info(
|
||||
'User purchased devices for kopeks', user_id=user.id, devices=request.devices, price_kopeks=price_kopeks
|
||||
)
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
@@ -2288,7 +2345,7 @@ async def purchase_devices(
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send admin notification for device purchase: {e}')
|
||||
logger.error('Failed to send admin notification for device purchase', error=e)
|
||||
|
||||
response = {
|
||||
'success': True,
|
||||
@@ -2311,7 +2368,7 @@ async def purchase_devices(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to purchase devices for user {user.id}: {e}')
|
||||
logger.error('Failed to purchase devices for user', user_id=user.id, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Не удалось обработать покупку устройств',
|
||||
@@ -2387,7 +2444,7 @@ async def save_traffic_cart(
|
||||
detail='Докупка трафика отключена',
|
||||
)
|
||||
|
||||
packages = settings.get_traffic_packages()
|
||||
packages = settings.get_traffic_topup_packages()
|
||||
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
|
||||
if not matching_pkg:
|
||||
raise HTTPException(
|
||||
@@ -2429,7 +2486,7 @@ async def save_traffic_cart(
|
||||
'description': f'Докупка {request.gb} ГБ трафика',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Cart saved for traffic purchase (cabinet save-cart) user {user.id}: +{request.gb} GB')
|
||||
logger.info('Cart saved for traffic purchase (cabinet save-cart) user +', user_id=user.id, gb=request.gb)
|
||||
|
||||
return {'success': True, 'cart_saved': True}
|
||||
|
||||
@@ -2504,7 +2561,9 @@ async def save_devices_cart(
|
||||
'source': 'cabinet',
|
||||
}
|
||||
await user_cart_service.save_user_cart(user.id, cart_data)
|
||||
logger.info(f'Cart saved for device purchase (cabinet save-cart) user {user.id}: +{request.devices} devices')
|
||||
logger.info(
|
||||
'Cart saved for device purchase (cabinet save-cart) user + devices', user_id=user.id, devices=request.devices
|
||||
)
|
||||
|
||||
return {'success': True, 'cart_saved': True}
|
||||
|
||||
@@ -2630,7 +2689,7 @@ def _load_app_config_from_file() -> dict[str, Any]:
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to load app-config.json: {e}')
|
||||
logger.error('Failed to load app-config.json', error=e)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -2773,10 +2832,11 @@ def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
|
||||
|
||||
# No scheme found
|
||||
logger.debug(
|
||||
f"_get_url_scheme_for_app: No scheme found for app '{app.get('name')}', "
|
||||
f'has blocks: {bool(app.get("blocks"))}, '
|
||||
f'has buttons: {bool(app.get("buttons"))}, '
|
||||
f'has urlScheme: {bool(app.get("urlScheme"))}'
|
||||
'_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme',
|
||||
get=app.get('name'),
|
||||
get_2=bool(app.get('blocks')),
|
||||
get_3=bool(app.get('buttons')),
|
||||
get_4=bool(app.get('urlScheme')),
|
||||
)
|
||||
return '', False
|
||||
|
||||
@@ -2822,7 +2882,7 @@ def _convert_remnawave_app_to_cabinet(app: dict[str, Any]) -> dict[str, Any]:
|
||||
# Debug log for conversion (не логируем отсутствие urlScheme - для Happ это нормально)
|
||||
app_name = app.get('name', 'unknown')
|
||||
if url_scheme:
|
||||
logger.debug(f"_convert_remnawave_app_to_cabinet: app '{app_name}' -> urlScheme='{url_scheme}'")
|
||||
logger.debug('_convert_remnawave_app_to_cabinet: app urlScheme', app_name=app_name, url_scheme=url_scheme)
|
||||
|
||||
# Smart block mapping: find blocks by their content, not just position
|
||||
# 1. First block is usually installation
|
||||
@@ -2917,7 +2977,12 @@ def _convert_remnawave_config_to_cabinet(config: dict[str, Any]) -> dict[str, An
|
||||
|
||||
|
||||
async def _load_app_config_async() -> dict[str, Any]:
|
||||
"""Load app config from RemnaWave (if configured) or local file."""
|
||||
"""Load app config from RemnaWave (if configured) or local file.
|
||||
|
||||
When config comes from RemnaWave, returns the original format with
|
||||
``_isRemnawave`` flag so the caller can serve it as-is (enriched with
|
||||
deep links) instead of converting to the legacy step-based format.
|
||||
"""
|
||||
remnawave_uuid = _get_remnawave_config_uuid()
|
||||
|
||||
if remnawave_uuid:
|
||||
@@ -2926,25 +2991,12 @@ async def _load_app_config_async() -> dict[str, Any]:
|
||||
async with service.get_api_client() as api:
|
||||
config = await api.get_subscription_page_config(remnawave_uuid)
|
||||
if config and config.config:
|
||||
logger.debug(f'Loaded app config from RemnaWave: {remnawave_uuid}')
|
||||
# Debug: log raw RemnaWave config structure
|
||||
import json
|
||||
|
||||
logger.debug(
|
||||
f'RemnaWave raw config: {json.dumps(config.config, ensure_ascii=False, indent=2)[:2000]}'
|
||||
)
|
||||
converted = _convert_remnawave_config_to_cabinet(config.config)
|
||||
logger.debug(f'Converted config platforms: {list(converted.get("platforms", {}).keys())}')
|
||||
# Log first app from each platform
|
||||
for platform, apps in converted.get('platforms', {}).items():
|
||||
if apps:
|
||||
first_app = apps[0]
|
||||
logger.debug(
|
||||
f'Platform {platform} first app: name={first_app.get("name")}, urlScheme={first_app.get("urlScheme")}'
|
||||
)
|
||||
return converted
|
||||
logger.debug('Loaded app config from RemnaWave', remnawave_uuid=remnawave_uuid)
|
||||
raw = dict(config.config)
|
||||
raw['_isRemnawave'] = True
|
||||
return raw
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to load RemnaWave config, falling back to file: {e}')
|
||||
logger.warning('Failed to load RemnaWave config, falling back to file', error=e)
|
||||
|
||||
# Fallback to local file
|
||||
return _load_app_config_from_file()
|
||||
@@ -2975,21 +3027,21 @@ def _create_deep_link(
|
||||
|
||||
scheme, uses_crypto = _get_url_scheme_for_app(app)
|
||||
if not scheme:
|
||||
logger.debug(f"_create_deep_link: no urlScheme for app '{app.get('name', 'unknown')}'")
|
||||
logger.debug('_create_deep_link: no urlScheme for app', get=app.get('name', 'unknown'))
|
||||
return None
|
||||
|
||||
# Pick the correct payload based on which template the app uses
|
||||
if uses_crypto:
|
||||
if not subscription_crypto_link:
|
||||
logger.debug(
|
||||
f"_create_deep_link: app '{app.get('name', 'unknown')}' requires crypto link but none available"
|
||||
'_create_deep_link: app requires crypto link but none available', get=app.get('name', 'unknown')
|
||||
)
|
||||
return None
|
||||
payload = subscription_crypto_link
|
||||
else:
|
||||
if not subscription_url:
|
||||
logger.debug(
|
||||
f"_create_deep_link: app '{app.get('name', 'unknown')}' requires subscription_url but none available"
|
||||
'_create_deep_link: app requires subscription_url but none available', get=app.get('name', 'unknown')
|
||||
)
|
||||
return None
|
||||
payload = subscription_url
|
||||
@@ -2998,7 +3050,7 @@ def _create_deep_link(
|
||||
try:
|
||||
payload = base64.b64encode(payload.encode('utf-8')).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to encode payload to base64: {e}')
|
||||
logger.warning('Failed to encode payload to base64', error=e)
|
||||
|
||||
return f'{scheme}{payload}'
|
||||
|
||||
@@ -3204,7 +3256,10 @@ async def update_countries(
|
||||
added_server_ids = await get_server_ids_by_uuids(db, added)
|
||||
if added_server_ids:
|
||||
await add_subscription_servers(db, user.subscription, added_server_ids, added_server_prices)
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
try:
|
||||
await add_user_to_servers(db, added_server_ids)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления счётчика серверов', error=e)
|
||||
|
||||
# Update connected squads
|
||||
user.subscription.connected_squads = selected_countries
|
||||
@@ -3219,7 +3274,7 @@ async def update_countries(
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, user.subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to sync countries with RemnaWave: {e}')
|
||||
logger.error('Failed to sync countries with RemnaWave', error=e)
|
||||
|
||||
await db.refresh(user.subscription)
|
||||
|
||||
@@ -3329,6 +3384,29 @@ async def get_happ_downloads(
|
||||
}
|
||||
|
||||
|
||||
def _resolve_button_url(
|
||||
url: str,
|
||||
subscription_url: str | None,
|
||||
subscription_crypto_link: str | None,
|
||||
) -> str:
|
||||
"""Resolve template variables in button URLs.
|
||||
|
||||
Matches remnawave/subscription-page frontend TemplateEngine:
|
||||
- {{SUBSCRIPTION_LINK}} -> plain subscription URL
|
||||
- {{HAPP_CRYPT3_LINK}} -> crypto link
|
||||
- {{HAPP_CRYPT4_LINK}} -> crypto link
|
||||
"""
|
||||
if not url:
|
||||
return url
|
||||
result = url
|
||||
if subscription_url:
|
||||
result = result.replace('{{SUBSCRIPTION_LINK}}', subscription_url)
|
||||
if subscription_crypto_link:
|
||||
result = result.replace('{{HAPP_CRYPT3_LINK}}', subscription_crypto_link)
|
||||
result = result.replace('{{HAPP_CRYPT4_LINK}}', subscription_crypto_link)
|
||||
return result
|
||||
|
||||
|
||||
@router.get('/app-config')
|
||||
async def get_app_config(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
@@ -3345,13 +3423,97 @@ async def get_app_config(
|
||||
|
||||
# Load config from RemnaWave (if configured) or local file
|
||||
config = await _load_app_config_async()
|
||||
platforms_raw = config.get('platforms', {})
|
||||
|
||||
is_remnawave = config.pop('_isRemnawave', False)
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
|
||||
# Строим platformNames из displayName каждой платформы RemnaWave
|
||||
platform_names: dict[str, Any] = {}
|
||||
for pk, pd in config.get('platforms', {}).items():
|
||||
if isinstance(pd, dict) and 'displayName' in pd:
|
||||
platform_names[pk] = pd['displayName']
|
||||
# Фоллбэк для платформ без displayName (en достаточно)
|
||||
fallback_names = {
|
||||
'ios': {'en': 'iPhone/iPad'},
|
||||
'android': {'en': 'Android'},
|
||||
'macos': {'en': 'macOS'},
|
||||
'windows': {'en': 'Windows'},
|
||||
'linux': {'en': 'Linux'},
|
||||
'androidTV': {'en': 'Android TV'},
|
||||
'appleTV': {'en': 'Apple TV'},
|
||||
}
|
||||
for k, v in fallback_names.items():
|
||||
if k not in platform_names:
|
||||
platform_names[k] = v
|
||||
|
||||
if is_remnawave:
|
||||
# ── RemnaWave original format ──
|
||||
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
|
||||
platforms: dict[str, Any] = {}
|
||||
for platform_key, platform_data in config.get('platforms', {}).items():
|
||||
if not isinstance(platform_data, dict):
|
||||
continue
|
||||
apps = platform_data.get('apps', [])
|
||||
if not isinstance(apps, list):
|
||||
continue
|
||||
|
||||
enriched_apps = []
|
||||
for app in apps:
|
||||
if not isinstance(app, dict):
|
||||
continue
|
||||
|
||||
# Generate deep link
|
||||
deep_link = None
|
||||
if subscription_url or subscription_crypto_link:
|
||||
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
|
||||
app['deepLink'] = deep_link
|
||||
|
||||
# Resolve templates only for subscriptionLink and copyButton (not external)
|
||||
for block in app.get('blocks', []):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
for btn in block.get('buttons', []):
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
btn_type = btn.get('type', '')
|
||||
if btn_type in ('subscriptionLink', 'copyButton'):
|
||||
url = btn.get('url', '') or btn.get('link', '')
|
||||
if url and '{{' in url:
|
||||
btn['resolvedUrl'] = _resolve_button_url(
|
||||
url,
|
||||
subscription_url,
|
||||
subscription_crypto_link,
|
||||
)
|
||||
|
||||
enriched_apps.append(app)
|
||||
|
||||
if enriched_apps:
|
||||
# Сохраняем platform-level поля (svgIconKey, displayName и т.д.)
|
||||
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
|
||||
platform_output['apps'] = enriched_apps
|
||||
platforms[platform_key] = platform_output
|
||||
|
||||
return {
|
||||
'isRemnawave': True,
|
||||
'platforms': platforms,
|
||||
'svgLibrary': config.get('svgLibrary', {}),
|
||||
'baseTranslations': config.get('baseTranslations'),
|
||||
'baseSettings': config.get('baseSettings'),
|
||||
'uiConfig': config.get('uiConfig', {}),
|
||||
'platformNames': platform_names,
|
||||
'hasSubscription': bool(subscription_url or subscription_crypto_link),
|
||||
'subscriptionUrl': subscription_url,
|
||||
'subscriptionCryptoLink': subscription_crypto_link,
|
||||
'hideLink': hide_link,
|
||||
'branding': config.get('brandingSettings', {}),
|
||||
}
|
||||
|
||||
# ── Legacy file-based format ──
|
||||
platforms_raw = config.get('platforms', {})
|
||||
if not isinstance(platforms_raw, dict):
|
||||
platforms_raw = {}
|
||||
|
||||
# Build response with deep links
|
||||
platforms = {}
|
||||
platforms_legacy: dict[str, Any] = {}
|
||||
for platform_key, apps in platforms_raw.items():
|
||||
if not isinstance(apps, list):
|
||||
continue
|
||||
@@ -3379,23 +3541,10 @@ async def get_app_config(
|
||||
platform_apps.append(app_data)
|
||||
|
||||
if platform_apps:
|
||||
platforms[platform_key] = platform_apps
|
||||
|
||||
# Platform display names for UI
|
||||
platform_names = {
|
||||
'ios': {'ru': 'iPhone/iPad', 'en': 'iPhone/iPad'},
|
||||
'android': {'ru': 'Android', 'en': 'Android'},
|
||||
'macos': {'ru': 'macOS', 'en': 'macOS'},
|
||||
'windows': {'ru': 'Windows', 'en': 'Windows'},
|
||||
'linux': {'ru': 'Linux', 'en': 'Linux'},
|
||||
'androidTV': {'ru': 'Android TV', 'en': 'Android TV'},
|
||||
'appleTV': {'ru': 'Apple TV', 'en': 'Apple TV'},
|
||||
}
|
||||
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
platforms_legacy[platform_key] = platform_apps
|
||||
|
||||
return {
|
||||
'platforms': platforms,
|
||||
'platforms': platforms_legacy,
|
||||
'platformNames': platform_names,
|
||||
'hasSubscription': bool(subscription_url or subscription_crypto_link),
|
||||
'subscriptionUrl': subscription_url if not hide_link else None,
|
||||
@@ -3460,7 +3609,7 @@ async def get_devices(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching devices: {e}')
|
||||
logger.error('Error fetching devices', error=e)
|
||||
return {
|
||||
'devices': [],
|
||||
'total': 0,
|
||||
@@ -3504,7 +3653,7 @@ async def delete_device(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error deleting device: {e}')
|
||||
logger.error('Error deleting device', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete device',
|
||||
@@ -3563,7 +3712,7 @@ async def delete_all_devices(
|
||||
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||||
deleted_count += 1
|
||||
except Exception as device_error:
|
||||
logger.error(f'Error deleting device {device_hwid}: {device_error}')
|
||||
logger.error('Error deleting device', device_hwid=device_hwid, device_error=device_error)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
@@ -3572,7 +3721,7 @@ async def delete_all_devices(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error deleting all devices: {e}')
|
||||
logger.error('Error deleting all devices', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete devices',
|
||||
@@ -3646,7 +3795,7 @@ async def get_device_reduction_info(
|
||||
if response and 'response' in response:
|
||||
connected_devices_count = response['response'].get('total', 0)
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting connected devices count: {e}')
|
||||
logger.error('Error getting connected devices count', error=e)
|
||||
|
||||
can_reduce = current_device_limit - min_device_limit
|
||||
|
||||
@@ -3730,8 +3879,11 @@ async def reduce_devices(
|
||||
if connected_devices_count > new_device_limit:
|
||||
devices_to_remove = connected_devices_count - new_device_limit
|
||||
logger.info(
|
||||
f'Removing {devices_to_remove} excess devices for user {user.id}: '
|
||||
f'had {connected_devices_count}, new limit {new_device_limit}'
|
||||
'Removing excess devices for user had new limit',
|
||||
devices_to_remove=devices_to_remove,
|
||||
user_id=user.id,
|
||||
connected_devices_count=connected_devices_count,
|
||||
new_device_limit=new_device_limit,
|
||||
)
|
||||
|
||||
# Sort by date (oldest first) and remove the last ones
|
||||
@@ -3748,11 +3900,11 @@ async def reduce_devices(
|
||||
delete_data = {'userUuid': user.remnawave_uuid, 'hwid': device_hwid}
|
||||
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
|
||||
devices_removed_count += 1
|
||||
logger.info(f'Removed device {device_hwid} for user {user.id}')
|
||||
logger.info('Removed device for user', device_hwid=device_hwid, user_id=user.id)
|
||||
except Exception as del_error:
|
||||
logger.error(f'Error removing device {device_hwid}: {del_error}')
|
||||
logger.error('Error removing device', device_hwid=device_hwid, del_error=del_error)
|
||||
except Exception as e:
|
||||
logger.error(f'Error checking/removing devices: {e}')
|
||||
logger.error('Error checking/removing devices', error=e)
|
||||
|
||||
old_device_limit = current_device_limit
|
||||
|
||||
@@ -3766,7 +3918,7 @@ async def reduce_devices(
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Error updating RemnaWave user: {e}')
|
||||
logger.error('Error updating RemnaWave user', error=e)
|
||||
|
||||
logger.info(
|
||||
f'User {user.id} reduced device limit from {old_device_limit} to {new_device_limit}'
|
||||
@@ -4200,7 +4352,7 @@ async def switch_tariff(
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, user.subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to sync tariff switch with RemnaWave: {e}')
|
||||
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
|
||||
|
||||
# Reset all devices on tariff switch
|
||||
devices_reset = False
|
||||
@@ -4210,9 +4362,9 @@ async def switch_tariff(
|
||||
async with service.get_api_client() as api:
|
||||
await api.reset_user_devices(user.remnawave_uuid)
|
||||
devices_reset = True
|
||||
logger.info(f'Reset all devices for user {user.id} on tariff switch')
|
||||
logger.info('Reset all devices for user on tariff switch', user_id=user.id)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to reset devices on tariff switch: {e}')
|
||||
logger.error('Failed to reset devices on tariff switch', error=e)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(user.subscription)
|
||||
@@ -4240,7 +4392,7 @@ async def switch_tariff(
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send admin notification for tariff switch: {e}')
|
||||
logger.error('Failed to send admin notification for tariff switch', error=e)
|
||||
|
||||
response = {
|
||||
'success': True,
|
||||
@@ -4339,7 +4491,7 @@ async def toggle_subscription_pause(
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
logger.error(f'Error enabling RemnaWave user on resume: {e}')
|
||||
logger.error('Error enabling RemnaWave user on resume', error=e)
|
||||
|
||||
if new_paused_state:
|
||||
message = 'Daily subscription paused'
|
||||
@@ -4473,7 +4625,7 @@ async def switch_traffic_package(
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, user.subscription)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to sync traffic switch with RemnaWave: {e}')
|
||||
logger.error('Failed to sync traffic switch with RemnaWave', error=e)
|
||||
|
||||
await db.refresh(user)
|
||||
await db.refresh(user.subscription)
|
||||
@@ -4614,7 +4766,7 @@ async def refresh_traffic(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error refreshing traffic for user {user.id}: {e}')
|
||||
logger.error('Error refreshing traffic for user', user_id=user.id, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to refresh traffic data',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Ticket notifications routes for cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -13,7 +13,7 @@ from app.database.models import User
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/tickets/notifications', tags=['Cabinet Ticket Notifications'])
|
||||
admin_router = APIRouter(prefix='/admin/tickets/notifications', tags=['Cabinet Admin Ticket Notifications'])
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Support tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -26,7 +26,7 @@ from ..schemas.tickets import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/tickets', tags=['Cabinet Tickets'])
|
||||
|
||||
@@ -164,7 +164,7 @@ async def create_ticket(
|
||||
try:
|
||||
await notify_admins_about_new_ticket(ticket, db)
|
||||
except Exception as e:
|
||||
logger.error(f'Error notifying admins about new ticket from cabinet: {e}')
|
||||
logger.error('Error notifying admins about new ticket from cabinet', error=e)
|
||||
|
||||
# Уведомить админов в кабинете
|
||||
try:
|
||||
@@ -173,7 +173,7 @@ async def create_ticket(
|
||||
# Отправить WebSocket уведомление
|
||||
await notify_admins_new_ticket(ticket.id, ticket.title, user.id)
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating cabinet notification for new ticket: {e}')
|
||||
logger.error('Error creating cabinet notification for new ticket', error=e)
|
||||
|
||||
messages = [_message_to_response(m) for m in ticket.messages]
|
||||
|
||||
@@ -284,7 +284,7 @@ async def add_ticket_message(
|
||||
try:
|
||||
await notify_admins_about_ticket_reply(ticket, request.message, db)
|
||||
except Exception as e:
|
||||
logger.error(f'Error notifying admins about ticket reply from cabinet: {e}')
|
||||
logger.error('Error notifying admins about ticket reply from cabinet', error=e)
|
||||
|
||||
# Уведомить админов в кабинете
|
||||
try:
|
||||
@@ -295,6 +295,6 @@ async def add_ticket_message(
|
||||
# Отправить WebSocket уведомление
|
||||
await notify_admins_ticket_reply(ticket.id, (request.message or '')[:100], user.id)
|
||||
except Exception as e:
|
||||
logger.error(f'Error creating cabinet notification for user reply: {e}')
|
||||
logger.error('Error creating cabinet notification for user reply', error=e)
|
||||
|
||||
return _message_to_response(message)
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.cabinet.auth.jwt_handler import get_token_payload
|
||||
@@ -14,7 +14,7 @@ from app.database.crud.user import get_user_by_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -42,10 +42,10 @@ class CabinetConnectionManager:
|
||||
self._admin_connections[user_id].add(websocket)
|
||||
|
||||
logger.debug(
|
||||
'Cabinet WS connected: user_id=%d, is_admin=%s, total_users=%d',
|
||||
user_id,
|
||||
is_admin,
|
||||
len(self._user_connections),
|
||||
'Cabinet WS connected: user_id is_admin total_users',
|
||||
user_id=user_id,
|
||||
is_admin=is_admin,
|
||||
user_connections_count=len(self._user_connections),
|
||||
)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, user_id: int) -> None:
|
||||
@@ -61,7 +61,7 @@ class CabinetConnectionManager:
|
||||
if not self._admin_connections[user_id]:
|
||||
del self._admin_connections[user_id]
|
||||
|
||||
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
|
||||
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
|
||||
|
||||
async def send_to_user(self, user_id: int, message: dict) -> None:
|
||||
"""Отправить сообщение конкретному пользователю."""
|
||||
@@ -79,7 +79,7 @@ class CabinetConnectionManager:
|
||||
try:
|
||||
await ws.send_text(data)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to send to user %d: %s', user_id, e)
|
||||
logger.warning('Failed to send to user', user_id=user_id, e=e)
|
||||
disconnected.add(ws)
|
||||
|
||||
# Cleanup disconnected
|
||||
@@ -105,7 +105,7 @@ class CabinetConnectionManager:
|
||||
try:
|
||||
await ws.send_text(data)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to send to admin %d: %s', user_id, e)
|
||||
logger.warning('Failed to send to admin', user_id=user_id, e=e)
|
||||
if user_id not in disconnected_by_user:
|
||||
disconnected_by_user[user_id] = set()
|
||||
disconnected_by_user[user_id].add(ws)
|
||||
@@ -152,7 +152,7 @@ async def verify_cabinet_ws_token(token: str) -> tuple[int | None, bool]:
|
||||
)
|
||||
return user_id, is_admin
|
||||
except (TimeoutError, OSError, ConnectionRefusedError) as e:
|
||||
logger.error('Database connection error in WS token verification: %s', str(e)[:200])
|
||||
logger.error('Database connection error in WS token verification', e=str(e)[:200])
|
||||
return None, False
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
token = websocket.query_params.get('token')
|
||||
|
||||
if not token:
|
||||
logger.debug('Cabinet WS: No token from %s', client_host)
|
||||
logger.debug('Cabinet WS: No token from', client_host=client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason='Unauthorized: No token')
|
||||
@@ -175,7 +175,7 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
user_id, is_admin = await verify_cabinet_ws_token(token)
|
||||
|
||||
if not user_id:
|
||||
logger.debug('Cabinet WS: Invalid token from %s', client_host)
|
||||
logger.debug('Cabinet WS: Invalid token from', client_host=client_host)
|
||||
# Принимаем и сразу закрываем с кодом ошибки
|
||||
await websocket.accept()
|
||||
await websocket.close(code=1008, reason='Unauthorized: Invalid token')
|
||||
@@ -184,9 +184,9 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
# Принимаем соединение
|
||||
try:
|
||||
await websocket.accept()
|
||||
logger.debug('Cabinet WS accepted: user_id=%d, is_admin=%s', user_id, is_admin)
|
||||
logger.debug('Cabinet WS accepted: user_id is_admin', user_id=user_id, is_admin=is_admin)
|
||||
except Exception as e:
|
||||
logger.error('Cabinet WS: Failed to accept from %s: %s', client_host, e)
|
||||
logger.error('Cabinet WS: Failed to accept from', client_host=client_host, e=e)
|
||||
return
|
||||
|
||||
# Регистрируем подключение
|
||||
@@ -213,17 +213,17 @@ async def cabinet_websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.send_json({'type': 'pong'})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning('Cabinet WS: Invalid JSON from user %d', user_id)
|
||||
logger.warning('Cabinet WS: Invalid JSON from user', user_id=user_id)
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception('Cabinet WS error for user %d: %s', user_id, e)
|
||||
logger.exception('Cabinet WS error for user', user_id=user_id, e=e)
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.debug('Cabinet WS disconnected: user_id=%d', user_id)
|
||||
logger.debug('Cabinet WS disconnected: user_id', user_id=user_id)
|
||||
except Exception as e:
|
||||
logger.exception('Cabinet WS error: %s', e)
|
||||
logger.exception('Cabinet WS error', e=e)
|
||||
finally:
|
||||
await cabinet_ws_manager.disconnect(websocket, user_id)
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
API роуты колеса удачи для пользователей.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -32,7 +32,7 @@ from app.database.models import User
|
||||
from app.services.wheel_service import wheel_service
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/wheel', tags=['Fortune Wheel'])
|
||||
|
||||
@@ -253,14 +253,16 @@ async def create_stars_invoice(
|
||||
result = response.json()
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error(f'Telegram API error: {result}')
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Ошибка создания инвойса',
|
||||
)
|
||||
|
||||
invoice_url = result['result']
|
||||
logger.info(f'Created Stars invoice for wheel spin: user={user.id}, stars={stars_amount}')
|
||||
logger.info(
|
||||
'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
invoice_url=invoice_url,
|
||||
@@ -268,7 +270,7 @@ async def create_stars_invoice(
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f'HTTP error creating invoice: {e}')
|
||||
logger.error('HTTP error creating invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Ошибка соединения с Telegram',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Pydantic schemas for cabinet pinned messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PinnedMessageMedia(BaseModel):
|
||||
type: str = Field(pattern=r'^(photo|video)$')
|
||||
file_id: str = Field(..., min_length=1, max_length=255)
|
||||
|
||||
|
||||
class PinnedMessageCreateRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=4000)
|
||||
media: PinnedMessageMedia | None = None
|
||||
send_before_menu: bool = True
|
||||
send_on_every_start: bool = True
|
||||
broadcast: bool = False
|
||||
|
||||
|
||||
class PinnedMessageUpdateRequest(BaseModel):
|
||||
content: str | None = Field(None, max_length=4000)
|
||||
send_before_menu: bool | None = None
|
||||
send_on_every_start: bool | None = None
|
||||
media: PinnedMessageMedia | None = None
|
||||
|
||||
|
||||
class PinnedMessageSettingsRequest(BaseModel):
|
||||
send_before_menu: bool | None = None
|
||||
send_on_every_start: bool | None = None
|
||||
|
||||
|
||||
class PinnedMessageResponse(BaseModel):
|
||||
id: int
|
||||
content: str | None
|
||||
media_type: str | None = None
|
||||
media_file_id: str | None = None
|
||||
send_before_menu: bool
|
||||
send_on_every_start: bool
|
||||
is_active: bool
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class PinnedMessageBroadcastResponse(BaseModel):
|
||||
message: PinnedMessageResponse
|
||||
sent_count: int
|
||||
failed_count: int
|
||||
|
||||
|
||||
class PinnedMessageUnpinResponse(BaseModel):
|
||||
unpinned_count: int
|
||||
failed_count: int
|
||||
was_active: bool
|
||||
|
||||
|
||||
class PinnedMessageListResponse(BaseModel):
|
||||
items: list[PinnedMessageResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
@@ -194,6 +194,12 @@ class TariffUpdateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
|
||||
|
||||
class TariffSortOrderRequest(BaseModel):
|
||||
"""Request to reorder tariffs."""
|
||||
|
||||
tariff_ids: list[int] = Field(..., min_length=1, description='Ordered list of tariff IDs')
|
||||
|
||||
|
||||
class TariffToggleResponse(BaseModel):
|
||||
"""Response after toggling tariff."""
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Schemas for admin traffic usage."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TrafficNodeInfo(BaseModel):
|
||||
node_uuid: str
|
||||
node_name: str
|
||||
country_code: str
|
||||
|
||||
|
||||
class UserTrafficItem(BaseModel):
|
||||
user_id: int
|
||||
telegram_id: int | None
|
||||
username: str | None
|
||||
email: str | None
|
||||
full_name: str
|
||||
tariff_name: str | None
|
||||
subscription_status: str | None
|
||||
traffic_limit_gb: float
|
||||
device_limit: int
|
||||
node_traffic: dict[str, int] # {node_uuid: total_bytes}
|
||||
total_bytes: int
|
||||
|
||||
|
||||
class TrafficUsageResponse(BaseModel):
|
||||
items: list[UserTrafficItem]
|
||||
nodes: list[TrafficNodeInfo]
|
||||
total: int
|
||||
offset: int
|
||||
limit: int
|
||||
period_days: int
|
||||
available_tariffs: list[str]
|
||||
available_statuses: list[str]
|
||||
|
||||
|
||||
class UserTrafficEnrichment(BaseModel):
|
||||
devices_connected: int = 0
|
||||
total_spent_kopeks: int = 0
|
||||
subscription_start_date: str | None = None
|
||||
subscription_end_date: str | None = None
|
||||
last_node_name: str | None = None
|
||||
|
||||
|
||||
class TrafficEnrichmentResponse(BaseModel):
|
||||
data: dict[int, UserTrafficEnrichment]
|
||||
|
||||
|
||||
class ExportCsvRequest(BaseModel):
|
||||
period: int = Field(30, ge=1, le=30)
|
||||
start_date: str | None = None
|
||||
end_date: str | None = None
|
||||
tariffs: str | None = None
|
||||
statuses: str | None = None
|
||||
nodes: str | None = None
|
||||
total_threshold_gb: float | None = Field(None, ge=0, description='Total GB/day threshold for risk column')
|
||||
node_threshold_gb: float | None = Field(None, ge=0, description='Per-node GB/day threshold for risk column')
|
||||
|
||||
|
||||
class ExportCsvResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
@@ -39,6 +39,17 @@ class SortByEnum(str, Enum):
|
||||
# === User Subscription Info ===
|
||||
|
||||
|
||||
class TrafficPurchaseItem(BaseModel):
|
||||
"""Individual traffic purchase record."""
|
||||
|
||||
id: int
|
||||
traffic_gb: int
|
||||
expires_at: datetime
|
||||
created_at: datetime
|
||||
days_remaining: int
|
||||
is_expired: bool
|
||||
|
||||
|
||||
class UserSubscriptionInfo(BaseModel):
|
||||
"""User subscription information."""
|
||||
|
||||
@@ -55,6 +66,8 @@ class UserSubscriptionInfo(BaseModel):
|
||||
autopay_enabled: bool = False
|
||||
is_active: bool = False
|
||||
days_remaining: int = 0
|
||||
purchased_traffic_gb: int = 0
|
||||
traffic_purchases: list[TrafficPurchaseItem] = []
|
||||
|
||||
|
||||
class UserPromoGroupInfo(BaseModel):
|
||||
@@ -189,9 +202,58 @@ class UserDetailResponse(BaseModel):
|
||||
promo_offer_discount_source: str | None = None
|
||||
promo_offer_discount_expires_at: datetime | None = None
|
||||
|
||||
# Campaign
|
||||
campaign_name: str | None = None
|
||||
campaign_id: int | None = None
|
||||
|
||||
# Recent transactions
|
||||
recent_transactions: list[UserTransactionItem] = []
|
||||
|
||||
# Remnawave UUID
|
||||
remnawave_uuid: str | None = None
|
||||
|
||||
|
||||
# === Panel Info ===
|
||||
|
||||
|
||||
class UserPanelInfoResponse(BaseModel):
|
||||
"""Panel info for user from Remnawave."""
|
||||
|
||||
found: bool = False
|
||||
trojan_password: str | None = None
|
||||
vless_uuid: str | None = None
|
||||
ss_password: str | None = None
|
||||
subscription_url: str | None = None
|
||||
happ_link: str | None = None
|
||||
used_traffic_bytes: int = 0
|
||||
lifetime_used_traffic_bytes: int = 0
|
||||
traffic_limit_bytes: int = 0
|
||||
first_connected_at: datetime | None = None
|
||||
online_at: datetime | None = None
|
||||
last_connected_node_uuid: str | None = None
|
||||
last_connected_node_name: str | None = None
|
||||
|
||||
|
||||
# === Node Usage ===
|
||||
|
||||
|
||||
class UserNodeUsageItem(BaseModel):
|
||||
"""Per-node traffic usage item."""
|
||||
|
||||
node_uuid: str
|
||||
node_name: str
|
||||
country_code: str = ''
|
||||
total_bytes: int
|
||||
daily_bytes: list[int] = []
|
||||
|
||||
|
||||
class UserNodeUsageResponse(BaseModel):
|
||||
"""Node usage response with 30-day daily breakdown."""
|
||||
|
||||
items: list[UserNodeUsageItem]
|
||||
categories: list[str] = []
|
||||
period_days: int = 30
|
||||
|
||||
|
||||
# === User Actions ===
|
||||
|
||||
@@ -236,6 +298,12 @@ class UpdateSubscriptionRequest(BaseModel):
|
||||
# For toggle_autopay
|
||||
autopay_enabled: bool | None = Field(None, description='Enable/disable autopay')
|
||||
|
||||
# For add_traffic action
|
||||
traffic_gb: int | None = Field(None, ge=1, description='Traffic GB to add')
|
||||
|
||||
# For remove_traffic action
|
||||
traffic_purchase_id: int | None = Field(None, description='Traffic purchase ID to remove')
|
||||
|
||||
# For create new subscription
|
||||
is_trial: bool | None = Field(None, description='Is trial subscription')
|
||||
device_limit: int | None = Field(None, ge=1, description='Device limit')
|
||||
@@ -299,6 +367,56 @@ class UpdatePromoGroupResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class UpdateReferralCommissionRequest(BaseModel):
|
||||
"""Request to update user referral commission percent."""
|
||||
|
||||
commission_percent: int | None = Field(
|
||||
None, ge=0, le=100, description='Referral commission percent (null for default)'
|
||||
)
|
||||
|
||||
|
||||
class UpdateReferralCommissionResponse(BaseModel):
|
||||
"""Response after referral commission update."""
|
||||
|
||||
success: bool
|
||||
old_commission_percent: int | None = None
|
||||
new_commission_percent: int | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class DeviceInfo(BaseModel):
|
||||
"""Individual device info."""
|
||||
|
||||
hwid: str
|
||||
platform: str = ''
|
||||
device_model: str = ''
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class UserDevicesResponse(BaseModel):
|
||||
"""User devices from panel."""
|
||||
|
||||
devices: list[DeviceInfo] = []
|
||||
total: int = 0
|
||||
device_limit: int = 0
|
||||
|
||||
|
||||
class DeleteDeviceResponse(BaseModel):
|
||||
"""Response after device deletion."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_hwid: str | None = None
|
||||
|
||||
|
||||
class ResetDevicesResponse(BaseModel):
|
||||
"""Response after resetting all devices."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deleted_count: int = 0
|
||||
|
||||
|
||||
class DeleteUserRequest(BaseModel):
|
||||
"""Request to delete user."""
|
||||
|
||||
@@ -392,6 +510,15 @@ class UserAvailableTariffItem(BaseModel):
|
||||
min_days: int = 1
|
||||
max_days: int = 365
|
||||
|
||||
# Device limits
|
||||
device_price_kopeks: int | None = None
|
||||
max_device_limit: int | None = None
|
||||
|
||||
# Traffic topup
|
||||
traffic_topup_enabled: bool = False
|
||||
traffic_topup_packages: dict[str, int] = {}
|
||||
max_topup_traffic_gb: int = 0
|
||||
|
||||
# Access info
|
||||
is_available: bool = True # Available for this user's promo group
|
||||
requires_promo_group: bool = False # Requires specific promo group
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""Email service for sending verification and password reset emails."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class EmailService:
|
||||
@@ -41,7 +42,7 @@ class EmailService:
|
||||
if smtp.has_extn('auth'):
|
||||
smtp.login(self.user, self.password)
|
||||
else:
|
||||
logger.debug(f'SMTP server {self.host} does not support AUTH, skipping authentication')
|
||||
logger.debug('SMTP server does not support AUTH, skipping authentication', host=self.host)
|
||||
|
||||
return smtp
|
||||
|
||||
@@ -94,11 +95,11 @@ class EmailService:
|
||||
with self._get_smtp_connection() as smtp:
|
||||
smtp.sendmail(self.from_email, to_email, msg.as_string())
|
||||
|
||||
logger.info(f'Email sent successfully to {to_email}')
|
||||
logger.info('Email sent successfully to', to_email=to_email)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send email to {to_email}: {e}')
|
||||
logger.error('Failed to send email to', to_email=to_email, error=e)
|
||||
return False
|
||||
|
||||
def send_verification_email(
|
||||
@@ -119,7 +120,7 @@ class EmailService:
|
||||
verification_token: Verification token
|
||||
verification_url: Base URL for verification (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -174,6 +175,16 @@ class EmailService:
|
||||
'ignore': 'Якщо ви не створювали акаунт, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'تایید آدرس ایمیل',
|
||||
'intro': 'از ثبتنام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
|
||||
'button': 'تایید ایمیل',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'ignore': 'اگر شما این حساب را ایجاد نکردهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -236,7 +247,7 @@ class EmailService:
|
||||
reset_token: Password reset token
|
||||
reset_url: Base URL for password reset (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -291,6 +302,16 @@ class EmailService:
|
||||
'warning': "Якщо ви не запитували скидання пароля, проігноруйте цей лист або зв'яжіться з підтримкою.",
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'بازنشانی رمز عبور',
|
||||
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
|
||||
'button': 'بازنشانی رمز عبور',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'warning': 'اگر شما درخواست بازنشانی رمز عبور ندادهاید، این ایمیل را نادیده بگیرید یا با پشتیبانی تماس بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -352,7 +373,7 @@ class EmailService:
|
||||
to_email: New email address
|
||||
code: 6-digit verification code
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template
|
||||
|
||||
@@ -401,6 +422,15 @@ class EmailService:
|
||||
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'کد تایید تغییر ایمیل',
|
||||
'intro': 'شما درخواست تغییر ایمیل دادهاید. برای تایید از کد زیر استفاده کنید:',
|
||||
'code_label': 'کد تایید شما:',
|
||||
'expires': f'این کد تا {expire_minutes} دقیقه معتبر است.',
|
||||
'ignore': 'اگر شما درخواست تغییر ایمیل ندادهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
|
||||
@@ -4,17 +4,17 @@ Service for managing email template overrides stored in the database.
|
||||
Custom templates override the hardcoded defaults from email_templates.py.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.database import AsyncSessionLocal
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_template_override(
|
||||
@@ -56,7 +56,9 @@ async def get_template_override(
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug('Не удалось получить override шаблона %s/%s: %s', notification_type, language, e)
|
||||
logger.debug(
|
||||
'Не удалось получить override шаблона /', notification_type=notification_type, language=language, e=e
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Email notification templates for different notification types.
|
||||
|
||||
Supports multiple languages: ru, en, zh, ua
|
||||
Supports multiple languages: ru, en, zh, ua, fa
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@@ -27,7 +27,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
Args:
|
||||
notification_type: Type of notification
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
context: Context data for template rendering
|
||||
|
||||
Returns:
|
||||
@@ -72,6 +72,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'This is an automated message. Please do not reply to this email.',
|
||||
'zh': '这是一封自动发送的邮件,请勿回复。',
|
||||
'ua': 'Це автоматичне повідомлення. Будь ласка, не відповідайте на цей лист.',
|
||||
'fa': 'این یک پیام خودکار است. لطفاً به این ایمیل پاسخ ندهید.',
|
||||
}
|
||||
footer_text = footer_texts.get(language, footer_texts['ru'])
|
||||
|
||||
@@ -182,6 +183,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'Open Dashboard',
|
||||
'zh': '打开控制面板',
|
||||
'ua': 'Відкрити особистий кабінет',
|
||||
'fa': 'باز کردن پنل کاربری',
|
||||
}
|
||||
text = texts.get(language, texts['en'])
|
||||
|
||||
|
||||
+133
-131
@@ -1,7 +1,6 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
@@ -11,6 +10,7 @@ from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import structlog
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -23,7 +23,7 @@ DEFAULT_DISPLAY_NAME_BANNED_KEYWORDS: list[str] = [
|
||||
USER_TAG_PATTERN = re.compile(r'^[A-Z0-9_]{1,16}$')
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -105,6 +105,25 @@ class Settings(BaseSettings):
|
||||
REMNAWAVE_AUTO_SYNC_TIMES: str = '03:00'
|
||||
CABINET_REMNA_SUB_CONFIG: str | None = None # UUID конфига страницы подписки из RemnaWave
|
||||
|
||||
# RemnaWave incoming webhooks (real-time event delivery from backend)
|
||||
REMNAWAVE_WEBHOOK_ENABLED: bool = False
|
||||
REMNAWAVE_WEBHOOK_PATH: str = '/remnawave-webhook'
|
||||
REMNAWAVE_WEBHOOK_SECRET: str | None = None # HMAC-SHA256 shared secret (min 32 chars)
|
||||
|
||||
# Webhook user notification toggles (what Telegram messages users receive from webhook events)
|
||||
WEBHOOK_NOTIFY_USER_ENABLED: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_STATUS: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRED: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_EXPIRING: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_LIMITED: bool = True
|
||||
WEBHOOK_NOTIFY_TRAFFIC_RESET: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_DELETED: bool = True
|
||||
WEBHOOK_NOTIFY_SUB_REVOKED: bool = True
|
||||
WEBHOOK_NOTIFY_FIRST_CONNECTED: bool = True
|
||||
WEBHOOK_NOTIFY_NOT_CONNECTED: bool = True
|
||||
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD: bool = True
|
||||
WEBHOOK_NOTIFY_DEVICES: bool = True
|
||||
|
||||
TRIAL_DURATION_DAYS: int = 3
|
||||
TRIAL_TRAFFIC_LIMIT_GB: int = 10
|
||||
TRIAL_DEVICE_LIMIT: int = 2
|
||||
@@ -112,6 +131,7 @@ class Settings(BaseSettings):
|
||||
TRIAL_PAYMENT_ENABLED: bool = False
|
||||
TRIAL_ACTIVATION_PRICE: int = 0
|
||||
TRIAL_USER_TAG: str | None = None
|
||||
TRIAL_DISABLED_FOR: str = 'none' # none, email, telegram, all
|
||||
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
|
||||
DEFAULT_DEVICE_LIMIT: int = 1
|
||||
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
|
||||
@@ -161,11 +181,6 @@ class Settings(BaseSettings):
|
||||
DEVICES_SELECTION_ENABLED: bool = True
|
||||
DEVICES_SELECTION_DISABLED_AMOUNT: int | None = 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 = ''
|
||||
|
||||
@@ -180,7 +195,7 @@ class Settings(BaseSettings):
|
||||
# Режим продаж подписок:
|
||||
# - classic: классический режим (выбор серверов, трафика, устройств, периода отдельно)
|
||||
# - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами)
|
||||
SALES_MODE: str = 'classic'
|
||||
SALES_MODE: str = 'tariffs'
|
||||
|
||||
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
|
||||
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
|
||||
@@ -236,6 +251,8 @@ class Settings(BaseSettings):
|
||||
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
|
||||
BLACKLIST_IGNORE_ADMINS: bool = True
|
||||
|
||||
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True
|
||||
|
||||
# Настройки простой покупки
|
||||
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
|
||||
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
|
||||
@@ -336,12 +353,6 @@ class Settings(BaseSettings):
|
||||
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если True - после пополнения показывает большое сообщение с кнопками:
|
||||
# "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: bool = False
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW: bool = False
|
||||
@@ -398,6 +409,7 @@ class Settings(BaseSettings):
|
||||
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
|
||||
MULENPAY_IFRAME_EXPECTED_ORIGIN: str | None = None
|
||||
MULENPAY_WEBSITE_URL: str | None = None
|
||||
|
||||
PAL24_ENABLED: bool = False
|
||||
PAL24_DISPLAY_NAME: str = 'PAL24'
|
||||
@@ -406,7 +418,6 @@ class Settings(BaseSettings):
|
||||
PAL24_SIGNATURE_TOKEN: str | None = None
|
||||
PAL24_BASE_URL: str = 'https://pal24.pro/api/v1/'
|
||||
PAL24_WEBHOOK_PATH: str = '/pal24-webhook'
|
||||
PAL24_WEBHOOK_PORT: int = 8084
|
||||
PAL24_PAYMENT_DESCRIPTION: str = 'Пополнение баланса'
|
||||
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
@@ -505,8 +516,10 @@ class Settings(BaseSettings):
|
||||
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
|
||||
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
|
||||
|
||||
MAIN_MENU_MODE: str = 'default'
|
||||
CONNECT_BUTTON_MODE: str = 'guide'
|
||||
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
|
||||
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
|
||||
CABINET_BUTTON_STYLE: str = ''
|
||||
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
|
||||
MINIAPP_CUSTOM_URL: str = ''
|
||||
MINIAPP_STATIC_PATH: str = 'miniapp'
|
||||
MINIAPP_PURCHASE_URL: str = ''
|
||||
@@ -528,7 +541,7 @@ class Settings(BaseSettings):
|
||||
SKIP_REFERRAL_CODE: bool = False
|
||||
|
||||
DEFAULT_LANGUAGE: str = 'ru'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en,ua,zh,fa'
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
|
||||
@@ -695,6 +708,23 @@ class Settings(BaseSettings):
|
||||
CABINET_EMAIL_AUTH_ENABLED: bool = True # Enable email registration/login in cabinet
|
||||
CABINET_URL: str = 'https://example.com/cabinet' # Base URL for cabinet (used in verification emails)
|
||||
|
||||
# OAuth 2.0 provider settings for cabinet
|
||||
OAUTH_GOOGLE_CLIENT_ID: str = ''
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: str = ''
|
||||
OAUTH_GOOGLE_ENABLED: bool = False
|
||||
|
||||
OAUTH_YANDEX_CLIENT_ID: str = ''
|
||||
OAUTH_YANDEX_CLIENT_SECRET: str = ''
|
||||
OAUTH_YANDEX_ENABLED: bool = False
|
||||
|
||||
OAUTH_DISCORD_CLIENT_ID: str = ''
|
||||
OAUTH_DISCORD_CLIENT_SECRET: str = ''
|
||||
OAUTH_DISCORD_ENABLED: bool = False
|
||||
|
||||
OAUTH_VK_CLIENT_ID: str = ''
|
||||
OAUTH_VK_CLIENT_SECRET: str = ''
|
||||
OAUTH_VK_ENABLED: bool = False
|
||||
|
||||
# SMTP settings for cabinet email
|
||||
SMTP_HOST: str | None = None
|
||||
SMTP_PORT: int = 587
|
||||
@@ -722,15 +752,16 @@ class Settings(BaseSettings):
|
||||
'default': 'default',
|
||||
'full': 'default',
|
||||
'standard': 'default',
|
||||
'text': 'text',
|
||||
'text_only': 'text',
|
||||
'textual': 'text',
|
||||
'minimal': 'text',
|
||||
'cabinet': 'cabinet',
|
||||
'text': 'cabinet',
|
||||
'text_only': 'cabinet',
|
||||
'textual': 'cabinet',
|
||||
'minimal': 'cabinet',
|
||||
}
|
||||
|
||||
mode = aliases.get(normalized, normalized)
|
||||
if mode not in {'default', 'text'}:
|
||||
raise ValueError('MAIN_MENU_MODE must be one of: default, text')
|
||||
if mode not in {'default', 'cabinet'}:
|
||||
raise ValueError('MAIN_MENU_MODE must be one of: default, cabinet')
|
||||
return mode
|
||||
|
||||
@field_validator('SERVER_STATUS_MODE', mode='before')
|
||||
@@ -1031,13 +1062,14 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
raw_username = template.format_map(values).strip()
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z._-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('._-')
|
||||
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
|
||||
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
|
||||
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
|
||||
|
||||
if not sanitized_username:
|
||||
sanitized_username = f'user_{identifier}'
|
||||
|
||||
return sanitized_username[:64]
|
||||
return sanitized_username[:36]
|
||||
|
||||
@staticmethod
|
||||
def parse_daily_time_list(raw_value: str | None) -> list[time]:
|
||||
@@ -1075,6 +1107,13 @@ class Settings(BaseSettings):
|
||||
def get_remnawave_auto_sync_times(self) -> list[time]:
|
||||
return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES)
|
||||
|
||||
def is_remnawave_webhook_enabled(self) -> bool:
|
||||
return (
|
||||
self.REMNAWAVE_WEBHOOK_ENABLED
|
||||
and bool(self.REMNAWAVE_WEBHOOK_SECRET)
|
||||
and len(self.REMNAWAVE_WEBHOOK_SECRET or '') >= 32
|
||||
)
|
||||
|
||||
def get_traffic_monitored_nodes(self) -> list[str]:
|
||||
"""Возвращает список UUID нод для мониторинга (пусто = все)"""
|
||||
if not self.TRAFFIC_MONITORED_NODES:
|
||||
@@ -1162,22 +1201,12 @@ class Settings(BaseSettings):
|
||||
|
||||
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 is_quick_amount_buttons_enabled(self) -> bool:
|
||||
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
|
||||
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
|
||||
|
||||
def get_available_languages(self) -> list[str]:
|
||||
defaults = ['ru', 'en', 'ua', 'zh']
|
||||
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
|
||||
|
||||
try:
|
||||
langs = self.AVAILABLE_LANGUAGES
|
||||
@@ -1268,7 +1297,7 @@ class Settings(BaseSettings):
|
||||
raise ValueError
|
||||
return time(hour=hours, minute=minutes)
|
||||
except (ValueError, AttributeError):
|
||||
logging.getLogger(__name__).warning('Некорректное значение ADMIN_REPORTS_SEND_TIME: %s', value)
|
||||
logger.warning('Некорректное значение ADMIN_REPORTS_SEND_TIME', send_time_value=value)
|
||||
return None
|
||||
|
||||
def kopeks_to_rubles(self, kopeks: int) -> float:
|
||||
@@ -1288,17 +1317,14 @@ class Settings(BaseSettings):
|
||||
|
||||
if len(cleaned) > 16:
|
||||
logger.warning(
|
||||
'Некорректная длина %s: максимум 16 символов, получено %s',
|
||||
setting_name,
|
||||
len(cleaned),
|
||||
'Некорректная длина : максимум 16 символов, получено',
|
||||
setting_name=setting_name,
|
||||
cleaned_count=len(cleaned),
|
||||
)
|
||||
return None
|
||||
|
||||
if not USER_TAG_PATTERN.fullmatch(cleaned):
|
||||
logger.warning(
|
||||
'Некорректный формат %s: допустимы только A-Z, 0-9 и подчёркивание',
|
||||
setting_name,
|
||||
)
|
||||
logger.warning('Некорректный формат : допустимы только A-Z, 0-9 и подчёркивание', setting_name=setting_name)
|
||||
return None
|
||||
|
||||
return cleaned
|
||||
@@ -1309,6 +1335,17 @@ class Settings(BaseSettings):
|
||||
def get_trial_user_tag(self) -> str | None:
|
||||
return self._normalize_user_tag(self.TRIAL_USER_TAG, 'TRIAL_USER_TAG')
|
||||
|
||||
def is_trial_disabled_for_user(self, auth_type: str | None) -> bool:
|
||||
disabled_for = self.TRIAL_DISABLED_FOR
|
||||
if disabled_for == 'all':
|
||||
return True
|
||||
# 'email' means all non-Telegram users (email, google, yandex, discord, vk, etc.)
|
||||
if disabled_for == 'email' and auth_type not in (None, 'telegram'):
|
||||
return True
|
||||
if disabled_for == 'telegram' and (auth_type is None or auth_type == 'telegram'):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_paid_subscription_user_tag(self) -> str | None:
|
||||
return self._normalize_user_tag(
|
||||
self.PAID_SUBSCRIPTION_USER_TAG,
|
||||
@@ -1328,8 +1365,12 @@ class Settings(BaseSettings):
|
||||
def get_main_menu_mode(self) -> str:
|
||||
return getattr(self, 'MAIN_MENU_MODE', 'default')
|
||||
|
||||
def is_cabinet_mode(self) -> bool:
|
||||
return self.get_main_menu_mode() == 'cabinet'
|
||||
|
||||
def is_text_main_menu_mode(self) -> bool:
|
||||
return self.get_main_menu_mode() == 'text'
|
||||
"""Backward-compatible alias for :meth:`is_cabinet_mode`."""
|
||||
return self.is_cabinet_mode()
|
||||
|
||||
def get_main_menu_miniapp_url(self) -> str | None:
|
||||
for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]:
|
||||
@@ -1404,9 +1445,9 @@ class Settings(BaseSettings):
|
||||
try:
|
||||
return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None
|
||||
except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений
|
||||
logging.getLogger(__name__).warning(
|
||||
'Некорректный идентификатор бота для внешней админки: %s',
|
||||
self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
|
||||
logger.warning(
|
||||
'Некорректный идентификатор бота для внешней админки',
|
||||
EXTERNAL_ADMIN_TOKEN_BOT_ID=self.EXTERNAL_ADMIN_TOKEN_BOT_ID,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1487,10 +1528,7 @@ class Settings(BaseSettings):
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
'Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT: %s',
|
||||
raw_value,
|
||||
)
|
||||
logger.warning('Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT', raw_value=raw_value)
|
||||
return None
|
||||
|
||||
if value < 0:
|
||||
@@ -1501,9 +1539,6 @@ class Settings(BaseSettings):
|
||||
def get_disabled_mode_device_limit(self) -> int | None:
|
||||
return self.get_devices_selection_disabled_amount()
|
||||
|
||||
def is_modem_enabled(self) -> bool:
|
||||
return bool(self.MODEM_ENABLED)
|
||||
|
||||
def is_tariffs_mode(self) -> bool:
|
||||
"""Проверяет, включен ли режим продаж 'Тарифы'."""
|
||||
return self.SALES_MODE == 'tariffs'
|
||||
@@ -1514,68 +1549,12 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_sales_mode(self) -> str:
|
||||
"""Возвращает текущий режим продаж."""
|
||||
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'classic'
|
||||
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'tariffs'
|
||||
|
||||
def get_trial_tariff_id(self) -> int:
|
||||
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
|
||||
return max(0, self.TRIAL_TARIFF_ID)
|
||||
|
||||
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:
|
||||
# TRIAL_PAYMENT_ENABLED - главный переключатель платной активации
|
||||
# Если выключен - триал бесплатный, независимо от цены
|
||||
@@ -1589,8 +1568,7 @@ class Settings(BaseSettings):
|
||||
value = int(self.TRIAL_ACTIVATION_PRICE)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
'Некорректное значение TRIAL_ACTIVATION_PRICE: %s',
|
||||
self.TRIAL_ACTIVATION_PRICE,
|
||||
'Некорректное значение TRIAL_ACTIVATION_PRICE', TRIAL_ACTIVATION_PRICE=self.TRIAL_ACTIVATION_PRICE
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -1709,7 +1687,7 @@ class Settings(BaseSettings):
|
||||
try:
|
||||
method_code = int(part)
|
||||
except ValueError:
|
||||
logger.warning('Некорректный код метода Platega: %s', part)
|
||||
logger.warning('Некорректный код метода Platega', part=part)
|
||||
continue
|
||||
if method_code in {2, 10, 11, 12, 13} and method_code not in seen:
|
||||
methods.append(method_code)
|
||||
@@ -1804,8 +1782,8 @@ class Settings(BaseSettings):
|
||||
|
||||
if minutes <= 0:
|
||||
logger.warning(
|
||||
'Некорректный интервал автопроверки платежей: %s. Используется значение по умолчанию 10 минут.',
|
||||
self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
|
||||
'Некорректный интервал автопроверки платежей: . Используется значение по умолчанию 10 минут.',
|
||||
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=self.PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES,
|
||||
)
|
||||
return 10
|
||||
|
||||
@@ -2159,22 +2137,13 @@ class Settings(BaseSettings):
|
||||
return self.REFERRAL_NOTIFICATIONS_ENABLED
|
||||
|
||||
def get_traffic_packages(self) -> list[dict]:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
packages = []
|
||||
config_str = self.TRAFFIC_PACKAGES_CONFIG.strip()
|
||||
|
||||
logger.debug(f"CONFIG STRING: '{config_str}'")
|
||||
|
||||
if not config_str:
|
||||
logger.debug('CONFIG EMPTY, USING FALLBACK')
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
logger.debug('PARSING CONFIG...')
|
||||
|
||||
for package_config in config_str.split(','):
|
||||
package_config = package_config.strip()
|
||||
if not package_config:
|
||||
@@ -2193,11 +2162,10 @@ class Settings(BaseSettings):
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
logger.debug(f'PARSED {len(packages)} packages from config')
|
||||
return packages if packages else self._get_fallback_traffic_packages()
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f'ERROR PARSING CONFIG: {e}')
|
||||
logger.warning('ERROR PARSING CONFIG', error=e)
|
||||
return self._get_fallback_traffic_packages()
|
||||
|
||||
def is_version_check_enabled(self) -> bool:
|
||||
@@ -2417,7 +2385,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_bot_run_mode(self) -> str:
|
||||
mode = (self.BOT_RUN_MODE or 'polling').strip().lower()
|
||||
if mode not in {'polling', 'webhook', 'both'}:
|
||||
if mode not in {'polling', 'webhook'}:
|
||||
return 'polling'
|
||||
return mode
|
||||
|
||||
@@ -2515,6 +2483,40 @@ class Settings(BaseSettings):
|
||||
return self.SMTP_FROM_EMAIL
|
||||
return self.SMTP_USER
|
||||
|
||||
# OAuth helpers
|
||||
def get_oauth_providers_config(self) -> dict[str, dict[str, str | bool]]:
|
||||
"""Return config for all OAuth providers (enabled or not)."""
|
||||
return {
|
||||
'google': {
|
||||
'client_id': self.OAUTH_GOOGLE_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_GOOGLE_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_GOOGLE_ENABLED,
|
||||
'display_name': 'Google',
|
||||
},
|
||||
'yandex': {
|
||||
'client_id': self.OAUTH_YANDEX_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_YANDEX_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_YANDEX_ENABLED,
|
||||
'display_name': 'Yandex',
|
||||
},
|
||||
'discord': {
|
||||
'client_id': self.OAUTH_DISCORD_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_DISCORD_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_DISCORD_ENABLED,
|
||||
'display_name': 'Discord',
|
||||
},
|
||||
'vk': {
|
||||
'client_id': self.OAUTH_VK_CLIENT_ID,
|
||||
'client_secret': self.OAUTH_VK_CLIENT_SECRET,
|
||||
'enabled': self.OAUTH_VK_ENABLED,
|
||||
'display_name': 'VK',
|
||||
},
|
||||
}
|
||||
|
||||
def get_enabled_oauth_provider_names(self) -> list[str]:
|
||||
"""Return list of enabled OAuth provider names."""
|
||||
return [name for name, cfg in self.get_oauth_providers_config().items() if cfg['enabled']]
|
||||
|
||||
# Ban System helpers
|
||||
def is_ban_system_enabled(self) -> bool:
|
||||
return bool(self.BAN_SYSTEM_ENABLED)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -17,7 +17,7 @@ from app.database.models import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_campaign(
|
||||
@@ -57,10 +57,10 @@ async def create_campaign(
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info(
|
||||
'📣 Создана рекламная кампания %s (start=%s, bonus=%s)',
|
||||
campaign.name,
|
||||
campaign.start_parameter,
|
||||
campaign.bonus_type,
|
||||
'📣 Создана рекламная кампания (start bonus=)',
|
||||
campaign_name=campaign.name,
|
||||
start_parameter=campaign.start_parameter,
|
||||
bonus_type=campaign.bonus_type,
|
||||
)
|
||||
return campaign
|
||||
|
||||
@@ -157,14 +157,14 @@ async def update_campaign(
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info('✏️ Обновлена рекламная кампания %s (%s)', campaign.name, update_data)
|
||||
logger.info('✏️ Обновлена рекламная кампания', campaign_name=campaign.name, update_data=update_data)
|
||||
return campaign
|
||||
|
||||
|
||||
async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
|
||||
await db.execute(delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id))
|
||||
await db.commit()
|
||||
logger.info('🗑️ Удалена рекламная кампания %s', campaign.name)
|
||||
logger.info('🗑️ Удалена рекламная кампания', campaign_name=campaign.name)
|
||||
return True
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ async def record_campaign_registration(
|
||||
await db.commit()
|
||||
await db.refresh(registration)
|
||||
|
||||
logger.info('📈 Регистрируем пользователя %s в кампании %s', user_id, campaign_id)
|
||||
logger.info('📈 Регистрируем пользователя в кампании', user_id=user_id, campaign_id=campaign_id)
|
||||
return registration
|
||||
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import CloudPaymentsPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_cloudpayments_payment(
|
||||
@@ -65,10 +65,10 @@ async def create_cloudpayments_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.debug(
|
||||
'Created CloudPayments payment: id=%s, invoice=%s, amount=%s',
|
||||
payment.id,
|
||||
invoice_id,
|
||||
amount_kopeks,
|
||||
'Created CloudPayments payment: id invoice amount',
|
||||
payment_id=payment.id,
|
||||
invoice_id=invoice_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -195,9 +195,7 @@ async def mark_cloudpayments_payment_as_paid(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Marked CloudPayments payment as paid: id=%s, invoice=%s',
|
||||
payment.id,
|
||||
payment.invoice_id,
|
||||
'Marked CloudPayments payment as paid: id invoice', payment_id=payment.id, invoice_id=payment.invoice_id
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import ContestAttempt, ContestRound, ContestTemplate, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# Templates
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import CryptoBotPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_cryptobot_payment(
|
||||
@@ -41,7 +41,13 @@ async def create_cryptobot_payment(
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f'Создан CryptoBot платеж: {invoice_id} на {amount} {asset} для пользователя {user_id}')
|
||||
logger.info(
|
||||
'Создан CryptoBot платеж: на для пользователя',
|
||||
invoice_id=invoice_id,
|
||||
amount=amount,
|
||||
asset=asset,
|
||||
user_id=user_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -78,7 +84,7 @@ async def update_cryptobot_payment_status(
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f'Обновлен статус CryptoBot платежа {invoice_id}: {status}')
|
||||
logger.info('Обновлен статус CryptoBot платежа', invoice_id=invoice_id, status=status)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -96,7 +102,7 @@ async def link_cryptobot_payment_to_transaction(
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f'Связан CryptoBot платеж {invoice_id} с транзакцией {transaction_id}')
|
||||
logger.info('Связан CryptoBot платеж с транзакцией', invoice_id=invoice_id, transaction_id=transaction_id)
|
||||
return payment
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -11,7 +11,7 @@ from app.database.crud.promo_offer_log import log_promo_offer_action
|
||||
from app.database.models import DiscountOffer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def upsert_discount_offer(
|
||||
@@ -178,17 +178,12 @@ async def mark_offer_claimed(
|
||||
details=details,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer claim log for offer %s: %s',
|
||||
offer.id,
|
||||
exc,
|
||||
)
|
||||
logger.warning('Failed to record promo offer claim log for offer', offer_id=offer.id, exc=exc)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer claim log failure: %s',
|
||||
rollback_error,
|
||||
'Failed to rollback session after promo offer claim log failure', rollback_error=rollback_error
|
||||
)
|
||||
|
||||
return offer
|
||||
@@ -239,16 +234,13 @@ async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer disable log for offer %s: %s',
|
||||
payload.get('offer_id'),
|
||||
exc,
|
||||
'Failed to record promo offer disable log for offer', payload=payload.get('offer_id'), exc=exc
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer disable log failure: %s',
|
||||
rollback_error,
|
||||
'Failed to rollback session after promo offer disable log failure', rollback_error=rollback_error
|
||||
)
|
||||
|
||||
return count
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FaqPage, FaqSetting
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_faq_setting(db: AsyncSession, language: str) -> FaqSetting | None:
|
||||
@@ -94,7 +94,7 @@ async def create_faq_page(
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info('✅ Создана страница FAQ %s для языка %s', page.id, language)
|
||||
logger.info('✅ Создана страница FAQ для языка', page_id=page.id, language=language)
|
||||
|
||||
return page
|
||||
|
||||
@@ -122,7 +122,7 @@ async def update_faq_page(
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info('✅ Страница FAQ %s обновлена', page.id)
|
||||
logger.info('✅ Страница FAQ обновлена', page_id=page.id)
|
||||
|
||||
return page
|
||||
|
||||
@@ -130,7 +130,7 @@ async def update_faq_page(
|
||||
async def delete_faq_page(db: AsyncSession, page_id: int) -> None:
|
||||
await db.execute(delete(FaqPage).where(FaqPage.id == page_id))
|
||||
await db.commit()
|
||||
logger.info('🗑️ Страница FAQ %s удалена', page_id)
|
||||
logger.info('🗑️ Страница FAQ удалена', page_id=page_id)
|
||||
|
||||
|
||||
async def bulk_update_order(
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""CRUD операции для платежей Freekassa."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import FreekassaPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_freekassa_payment(
|
||||
@@ -41,7 +41,7 @@ async def create_freekassa_payment(
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Создан платеж Freekassa: order_id={order_id}, user_id={user_id}')
|
||||
logger.info('Создан платеж Freekassa: order_id=, user_id', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -92,7 +92,12 @@ async def update_freekassa_payment_status(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Обновлен статус платежа Freekassa: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
|
||||
logger.info(
|
||||
'Обновлен статус платежа Freekassa: order_id=, status=, is_paid',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import HeleketPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_heleket_payment(
|
||||
@@ -50,12 +50,12 @@ async def create_heleket_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Создан Heleket платеж: uuid=%s order_id=%s amount=%s %s для пользователя %s',
|
||||
uuid,
|
||||
order_id,
|
||||
amount,
|
||||
currency,
|
||||
user_id,
|
||||
'Создан Heleket платеж: uuid= order_id= amount= для пользователя',
|
||||
uuid=uuid,
|
||||
order_id=order_id,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -107,7 +107,7 @@ async def update_heleket_payment(
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error('Heleket платеж с uuid=%s не найден', uuid)
|
||||
logger.error('Heleket платеж с uuid= не найден', uuid=uuid)
|
||||
return None
|
||||
|
||||
if status is not None:
|
||||
@@ -135,11 +135,11 @@ async def update_heleket_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Обновлен Heleket платеж %s: статус=%s payer_amount=%s %s',
|
||||
uuid,
|
||||
payment.status,
|
||||
payment.payer_amount,
|
||||
payment.payer_currency,
|
||||
'Обновлен Heleket платеж : статус= payer_amount',
|
||||
uuid=uuid,
|
||||
payment_status=payment.status,
|
||||
payer_amount=payment.payer_amount,
|
||||
payer_currency=payment.payer_currency,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -153,7 +153,7 @@ async def link_heleket_payment_to_transaction(
|
||||
payment = await get_heleket_payment_by_uuid(db, uuid)
|
||||
|
||||
if not payment:
|
||||
logger.error('Не найден Heleket платеж для связи с транзакцией: %s', uuid)
|
||||
logger.error('Не найден Heleket платеж для связи с транзакцией', uuid=uuid)
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
@@ -162,10 +162,6 @@ async def link_heleket_payment_to_transaction(
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Heleket платеж %s связан с транзакцией %s',
|
||||
uuid,
|
||||
transaction_id,
|
||||
)
|
||||
logger.info('Heleket платеж связан с транзакцией', uuid=uuid, transaction_id=transaction_id)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""CRUD операции для платежей KassaAI."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import KassaAiPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_kassa_ai_payment(
|
||||
@@ -43,7 +43,7 @@ async def create_kassa_ai_payment(
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Создан платеж KassaAI: order_id={order_id}, user_id={user_id}')
|
||||
logger.info('Создан платеж KassaAI: order_id=, user_id', order_id=order_id, user_id=user_id)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -94,7 +94,12 @@ async def update_kassa_ai_payment_status(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(f'Обновлен статус платежа KassaAI: order_id={payment.order_id}, status={status}, is_paid={is_paid}')
|
||||
logger.info(
|
||||
'Обновлен статус платежа KassaAI: order_id=, status=, is_paid',
|
||||
order_id=payment.order_id,
|
||||
status=status,
|
||||
is_paid=is_paid,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.config import settings
|
||||
from app.database.models import MulenPayPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_mulenpay_payment(
|
||||
@@ -41,12 +41,12 @@ async def create_mulenpay_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Создан %s платеж #%s (uuid=%s) на сумму %s копеек для пользователя %s',
|
||||
settings.get_mulenpay_display_name(),
|
||||
payment.mulen_payment_id,
|
||||
uuid,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
'Создан платеж # (uuid=) на сумму копеек для пользователя',
|
||||
get_mulenpay_display_name=settings.get_mulenpay_display_name(),
|
||||
mulen_payment_id=payment.mulen_payment_id,
|
||||
uuid=uuid,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SentNotification
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def notification_sent(
|
||||
|
||||
+12
-16
@@ -2,17 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Pal24Payment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_pal24_payment(
|
||||
@@ -51,11 +51,11 @@ async def create_pal24_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Создан Pal24 платеж #%s для пользователя %s: %s копеек (статус %s)',
|
||||
payment.id,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
status,
|
||||
'Создан Pal24 платеж # для пользователя : копеек (статус)',
|
||||
payment_id=payment.id,
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=status,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -128,10 +128,10 @@ async def update_pal24_payment_status(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Обновлен Pal24 платеж %s: статус=%s, is_paid=%s',
|
||||
payment.bill_id,
|
||||
payment.status,
|
||||
payment.is_paid,
|
||||
'Обновлен Pal24 платеж : статус is_paid',
|
||||
bill_id=payment.bill_id,
|
||||
payment_status=payment.status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -145,9 +145,5 @@ async def link_pal24_payment_to_transaction(
|
||||
await db.execute(update(Pal24Payment).where(Pal24Payment.id == payment.id).values(transaction_id=transaction_id))
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
logger.info(
|
||||
'Pal24 платеж %s привязан к транзакции %s',
|
||||
payment.bill_id,
|
||||
transaction_id,
|
||||
)
|
||||
logger.info('Pal24 платеж привязан к транзакции', bill_id=payment.bill_id, transaction_id=transaction_id)
|
||||
return payment
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PlategaPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_platega_payment(
|
||||
@@ -55,11 +55,11 @@ async def create_platega_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Создан Platega платеж #%s (tx=%s) на сумму %s копеек для пользователя %s',
|
||||
payment.id,
|
||||
platega_transaction_id,
|
||||
amount_kopeks,
|
||||
user_id,
|
||||
'Создан Platega платеж # (tx=) на сумму копеек для пользователя',
|
||||
payment_id=payment.id,
|
||||
platega_transaction_id=platega_transaction_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -14,7 +14,7 @@ from app.database.models import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_poll(
|
||||
@@ -97,7 +97,7 @@ async def delete_poll(db: AsyncSession, poll_id: int) -> bool:
|
||||
|
||||
await db.delete(poll)
|
||||
await db.commit()
|
||||
logger.info('🗑️ Удалён опрос %s', poll_id)
|
||||
logger.info('🗑️ Удалён опрос', poll_id=poll_id)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PrivacyPolicy
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_privacy_policy(db: AsyncSession, language: str) -> PrivacyPolicy | None:
|
||||
@@ -38,11 +38,7 @@ async def upsert_privacy_policy(
|
||||
await db.commit()
|
||||
await db.refresh(policy)
|
||||
|
||||
logger.info(
|
||||
'✅ Политика конфиденциальности для языка %s обновлена (ID: %s)',
|
||||
language,
|
||||
policy.id,
|
||||
)
|
||||
logger.info('✅ Политика конфиденциальности для языка обновлена (ID:)', language=language, policy_id=policy.id)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -25,7 +24,7 @@ def _normalize_period_discounts(period_discounts: dict[int, int] | None) -> dict
|
||||
return normalized
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_promo_groups_with_counts(
|
||||
@@ -194,11 +193,7 @@ async def update_promo_group(
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
logger.info(
|
||||
"Обновлена промогруппа '%s' (id=%s)",
|
||||
group.name,
|
||||
group.id,
|
||||
)
|
||||
logger.info("Обновлена промогруппа '' (id=)", group_name=group.name, group_id=group.id)
|
||||
return group
|
||||
|
||||
|
||||
@@ -246,10 +241,10 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
|
||||
group.name,
|
||||
group.id,
|
||||
default_group.name,
|
||||
"Промогруппа '' (id=) удалена, пользователи переведены в ''",
|
||||
group_name=group.name,
|
||||
group_id=group.id,
|
||||
default_group_name=default_group.name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -9,7 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import PromoOfferLog
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def log_promo_offer_action(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_promocode_by_code(db: AsyncSession, code: str) -> PromoCode | None:
|
||||
@@ -78,9 +78,9 @@ async def create_promocode(
|
||||
await db.refresh(promocode)
|
||||
|
||||
if promo_group_id:
|
||||
logger.info(f'✅ Создан промокод: {code} с промогруппой ID {promo_group_id}')
|
||||
logger.info('✅ Создан промокод: с промогруппой ID', code=code, promo_group_id=promo_group_id)
|
||||
else:
|
||||
logger.info(f'✅ Создан промокод: {code}')
|
||||
logger.info('✅ Создан промокод', code=code)
|
||||
return promocode
|
||||
|
||||
|
||||
@@ -97,11 +97,11 @@ async def use_promocode(db: AsyncSession, promocode_id: int, user_id: int) -> bo
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'✅ Промокод {promocode.code} использован пользователем {user_id}')
|
||||
logger.info('✅ Промокод использован пользователем', code=promocode.code, user_id=user_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка использования промокода: {e}')
|
||||
logger.error('Ошибка использования промокода', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
@@ -120,7 +120,7 @@ async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int
|
||||
await db.commit()
|
||||
await db.refresh(promocode_use)
|
||||
|
||||
logger.info(f'📝 Записано использование промокода {promocode_id} пользователем {user_id}')
|
||||
logger.info('📝 Записано использование промокода пользователем', promocode_id=promocode_id, user_id=user_id)
|
||||
return promocode_use
|
||||
|
||||
|
||||
@@ -131,6 +131,17 @@ async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, pro
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def count_user_recent_activations(db: AsyncSession, user_id: int, hours: int = 24) -> int:
|
||||
"""Подсчитывает количество активаций промокодов пользователем за последние N часов."""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(hours=hours)
|
||||
result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.used_at >= cutoff))
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
async def get_user_promocodes(db: AsyncSession, user_id: int) -> list[PromoCodeUse]:
|
||||
result = await db.execute(
|
||||
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id).order_by(PromoCodeUse.used_at.desc())
|
||||
@@ -183,11 +194,11 @@ async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
|
||||
await db.delete(promocode)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'🗑️ Удален промокод: {promocode.code}')
|
||||
logger.info('🗑️ Удален промокод', code=promocode.code)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка удаления промокода: {e}')
|
||||
logger.error('Ошибка удаления промокода', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import PublicOffer
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_public_offer(db: AsyncSession, language: str) -> PublicOffer | None:
|
||||
@@ -38,11 +38,7 @@ async def upsert_public_offer(
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
|
||||
logger.info(
|
||||
'✅ Публичная оферта для языка %s обновлена (ID: %s)',
|
||||
language,
|
||||
offer.id,
|
||||
)
|
||||
logger.info('✅ Публичная оферта для языка обновлена (ID:)', language=language, offer_id=offer.id)
|
||||
|
||||
return offer
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import ReferralEarning, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_referral_earning(
|
||||
@@ -31,7 +31,9 @@ async def create_referral_earning(
|
||||
await db.commit()
|
||||
await db.refresh(earning)
|
||||
|
||||
logger.info(f'💰 Создан реферальный заработок: {amount_kopeks / 100}₽ для пользователя {user_id}')
|
||||
logger.info(
|
||||
'💰 Создан реферальный заработок: ₽ для пользователя', amount_kopeks=amount_kopeks / 100, user_id=user_id
|
||||
)
|
||||
return earning
|
||||
|
||||
|
||||
@@ -211,7 +213,10 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
month_earnings = month_referral_earnings_result.scalar() + month_transaction_earnings_result.scalar()
|
||||
|
||||
logger.info(
|
||||
f'Реферальная статистика: {users_with_referrals} рефералов, {active_referrers} рефереров, выплачено {total_paid} копеек'
|
||||
'Реферальная статистика: рефералов, рефереров, выплачено копеек',
|
||||
users_with_referrals=users_with_referrals,
|
||||
active_referrers=active_referrers,
|
||||
total_paid=total_paid,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import date, datetime, time
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -16,7 +16,7 @@ from app.database.models import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_referral_contest(
|
||||
@@ -725,7 +725,12 @@ async def sync_contest_events(
|
||||
# Конец дня: 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)
|
||||
logger.info(
|
||||
'Синхронизация конкурса : период с по',
|
||||
contest_id=contest_id,
|
||||
contest_start=contest_start,
|
||||
contest_end=contest_end,
|
||||
)
|
||||
|
||||
stats = {
|
||||
'updated': 0,
|
||||
@@ -815,7 +820,11 @@ async def sync_contest_events(
|
||||
# Логируем значительные изменения
|
||||
if abs(old_amount - total_paid) > 10000: # больше 100 руб разницы
|
||||
logger.debug(
|
||||
'Событие %s (реферал %s): %s -> %s коп.', event.id, event.referral_id, old_amount, total_paid
|
||||
'Событие (реферал): -> коп.',
|
||||
event_id=event.id,
|
||||
referral_id=event.referral_id,
|
||||
old_amount=old_amount,
|
||||
total_paid=total_paid,
|
||||
)
|
||||
else:
|
||||
stats['skipped'] += 1
|
||||
@@ -824,11 +833,11 @@ async def sync_contest_events(
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Синхронизация конкурса %s завершена: обновлено %s, пропущено %s, сумма %s коп.',
|
||||
contest_id,
|
||||
stats['updated'],
|
||||
stats['skipped'],
|
||||
stats['total_amount'],
|
||||
'Синхронизация конкурса завершена: обновлено , пропущено , сумма коп.',
|
||||
contest_id=contest_id,
|
||||
stats=stats['updated'],
|
||||
stats_2=stats['skipped'],
|
||||
stats_3=stats['total_amount'],
|
||||
)
|
||||
|
||||
return stats
|
||||
@@ -861,7 +870,9 @@ async def cleanup_invalid_contest_events(
|
||||
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)
|
||||
|
||||
logger.info('Очистка конкурса %s: период с %s по %s', contest_id, contest_start, contest_end)
|
||||
logger.info(
|
||||
'Очистка конкурса : период с по', contest_id=contest_id, contest_start=contest_start, contest_end=contest_end
|
||||
)
|
||||
|
||||
# Считаем сколько было событий до очистки
|
||||
total_before_result = await db.execute(
|
||||
@@ -905,11 +916,11 @@ async def cleanup_invalid_contest_events(
|
||||
remaining = int(remaining_result.scalar_one() or 0)
|
||||
|
||||
logger.info(
|
||||
'Очистка конкурса %s завершена: удалено %s невалидных событий, осталось %s валидных (было %s)',
|
||||
contest_id,
|
||||
deleted,
|
||||
remaining,
|
||||
total_before,
|
||||
'Очистка конкурса завершена: удалено невалидных событий, осталось валидных (было)',
|
||||
contest_id=contest_id,
|
||||
deleted=deleted,
|
||||
remaining=remaining,
|
||||
total_before=total_before,
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import ServiceRule
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_rules_by_language(db: AsyncSession, language: str = 'ru') -> ServiceRule | None:
|
||||
@@ -38,7 +38,7 @@ async def create_or_update_rules(
|
||||
await db.commit()
|
||||
await db.refresh(new_rules)
|
||||
|
||||
logger.info(f'✅ Правила для языка {language} обновлены (ID: {new_rules.id})')
|
||||
logger.info('✅ Правила для языка обновлены (ID: )', language=language, new_rules_id=new_rules.id)
|
||||
return new_rules
|
||||
|
||||
|
||||
@@ -53,12 +53,14 @@ async def clear_all_rules(db: AsyncSession, language: str = 'ru') -> bool:
|
||||
await db.commit()
|
||||
|
||||
rows_affected = result.rowcount
|
||||
logger.info(f'✅ Очищены правила для языка {language}. Деактивировано записей: {rows_affected}')
|
||||
logger.info(
|
||||
'✅ Очищены правила для языка . Деактивировано записей', language=language, rows_affected=rows_affected
|
||||
)
|
||||
|
||||
return rows_affected > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка при очистке правил для языка {language}: {e}')
|
||||
logger.error('❌ Ошибка при очистке правил для языка', language=language, error=e)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
@@ -102,7 +104,7 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str =
|
||||
rule_to_restore = result.scalar_one_or_none()
|
||||
|
||||
if not rule_to_restore:
|
||||
logger.warning(f'Правило с ID {rule_id} не найдено для языка {language}')
|
||||
logger.warning('Правило с ID не найдено для языка', rule_id=rule_id, language=language)
|
||||
return None
|
||||
|
||||
await db.execute(
|
||||
@@ -119,11 +121,13 @@ async def restore_rules_version(db: AsyncSession, rule_id: int, language: str =
|
||||
await db.commit()
|
||||
await db.refresh(restored_rule)
|
||||
|
||||
logger.info(f'✅ Восстановлена версия правил ID {rule_id} как новое правило ID {restored_rule.id}')
|
||||
logger.info(
|
||||
'✅ Восстановлена версия правил ID как новое правило ID', rule_id=rule_id, restored_rule_id=restored_rule.id
|
||||
)
|
||||
return restored_rule
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка при восстановлении правил ID {rule_id}: {e}')
|
||||
logger.error('❌ Ошибка при восстановлении правил ID', rule_id=rule_id, error=e)
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
@@ -156,5 +160,5 @@ async def get_rules_statistics(db: AsyncSession) -> dict:
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка при получении статистики правил: {e}')
|
||||
logger.error('❌ Ошибка при получении статистики правил', error=e)
|
||||
return {'total_active': 0, 'total_all_time': 0, 'languages': {}, 'total_languages': 0, 'error': str(e)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import logging
|
||||
import random
|
||||
from collections.abc import Iterable, Sequence
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import (
|
||||
String,
|
||||
and_,
|
||||
@@ -23,11 +23,12 @@ from app.database.models import (
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
Tariff,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def _get_default_promo_group_id(db: AsyncSession) -> int | None:
|
||||
@@ -63,7 +64,7 @@ async def create_server_squad(
|
||||
promo_groups = promo_groups_result.scalars().all()
|
||||
|
||||
if len(promo_groups) != len(normalized_group_ids):
|
||||
logger.warning('Не все промогруппы найдены при создании сервера %s', display_name)
|
||||
logger.warning('Не все промогруппы найдены при создании сервера', display_name=display_name)
|
||||
|
||||
server_squad = ServerSquad(
|
||||
squad_uuid=squad_uuid,
|
||||
@@ -83,7 +84,7 @@ async def create_server_squad(
|
||||
await db.commit()
|
||||
await db.refresh(server_squad)
|
||||
|
||||
logger.info(f'✅ Создан сервер {display_name} (UUID: {squad_uuid})')
|
||||
logger.info('✅ Создан сервер (UUID: )', display_name=display_name, squad_uuid=squad_uuid)
|
||||
return server_squad
|
||||
|
||||
|
||||
@@ -266,13 +267,17 @@ async def delete_server_squad(db: AsyncSession, server_id: int) -> bool:
|
||||
connections_count = connections_result.scalar()
|
||||
|
||||
if connections_count > 0:
|
||||
logger.warning(f'⚠ Нельзя удалить сервер {server_id}: есть активные подключения ({connections_count})')
|
||||
logger.warning(
|
||||
'⚠ Нельзя удалить сервер есть активные подключения',
|
||||
server_id=server_id,
|
||||
connections_count=connections_count,
|
||||
)
|
||||
return False
|
||||
|
||||
await db.execute(delete(ServerSquad).where(ServerSquad.id == server_id))
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'🗑️ Удален сервер (ID: {server_id})')
|
||||
logger.info('🗑️ Удален сервер (ID: )', server_id=server_id)
|
||||
return True
|
||||
|
||||
|
||||
@@ -321,11 +326,7 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
|
||||
|
||||
for server in removed_servers:
|
||||
logger.info(
|
||||
'🗑️ Удаляется сервер %s (UUID: %s)',
|
||||
server.display_name,
|
||||
server.squad_uuid,
|
||||
)
|
||||
logger.info('🗑️ Удаляется сервер (UUID:)', display_name=server.display_name, squad_uuid=server.squad_uuid)
|
||||
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids)))
|
||||
|
||||
@@ -362,18 +363,37 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
cleaned_subscriptions += 1
|
||||
|
||||
# Clean up stale UUIDs from tariff allowed_squads
|
||||
cleaned_tariffs = 0
|
||||
tariffs_result = await db.execute(select(Tariff))
|
||||
for tariff in tariffs_result.scalars().all():
|
||||
current = list(tariff.allowed_squads or [])
|
||||
if not current:
|
||||
continue
|
||||
filtered = [u for u in current if u not in removed_uuids]
|
||||
if len(filtered) != len(current):
|
||||
tariff.allowed_squads = filtered
|
||||
tariff.updated_at = datetime.utcnow()
|
||||
cleaned_tariffs += 1
|
||||
logger.info(
|
||||
'🧹 Тариф "%s" (ID: %s): удалены несуществующие сквады %s',
|
||||
tariff.name,
|
||||
tariff.id,
|
||||
[u for u in current if u in removed_uuids],
|
||||
)
|
||||
|
||||
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
|
||||
removed = len(removed_servers)
|
||||
|
||||
if cleaned_subscriptions:
|
||||
logger.info(
|
||||
'🧹 Обновлены подписки после удаления серверов: %s',
|
||||
cleaned_subscriptions,
|
||||
)
|
||||
logger.info('🧹 Обновлены подписки после удаления серверов', cleaned_subscriptions=cleaned_subscriptions)
|
||||
|
||||
if cleaned_tariffs:
|
||||
logger.info('🧹 Обновлены тарифы после удаления серверов', cleaned_tariffs=cleaned_tariffs)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'🔄 Синхронизация завершена: +{created} ~{updated} -{removed}')
|
||||
logger.info('🔄 Синхронизация завершена: + ~', created=created, updated=updated, removed=removed)
|
||||
return created, updated, removed
|
||||
|
||||
|
||||
@@ -733,40 +753,90 @@ async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int
|
||||
|
||||
async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool:
|
||||
try:
|
||||
for server_id in server_squad_ids:
|
||||
for server_id in sorted(server_squad_ids):
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=ServerSquad.current_users + 1)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}')
|
||||
await db.flush()
|
||||
logger.info('✅ Увеличен счетчик пользователей для серверов', server_squad_ids=server_squad_ids)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка увеличения счетчика пользователей: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
logger.error('Ошибка увеличения счетчика пользователей', error=e)
|
||||
raise
|
||||
|
||||
|
||||
async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool:
|
||||
try:
|
||||
for server_id in server_squad_ids:
|
||||
for server_id in sorted(server_squad_ids):
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}')
|
||||
await db.flush()
|
||||
logger.info('✅ Уменьшен счетчик пользователей для серверов', server_squad_ids=server_squad_ids)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка уменьшения счетчика пользователей: {e}')
|
||||
await db.rollback()
|
||||
return False
|
||||
logger.error('Ошибка уменьшения счетчика пользователей', error=e)
|
||||
raise
|
||||
|
||||
|
||||
async def update_server_user_counts(
|
||||
db: AsyncSession,
|
||||
add_ids: list[int] | None = None,
|
||||
remove_ids: list[int] | None = None,
|
||||
) -> None:
|
||||
"""Increment and decrement server user counters in a single sorted pass.
|
||||
|
||||
Prevents deadlocks by acquiring row locks in consistent ID order
|
||||
across both add and remove operations within one transaction.
|
||||
"""
|
||||
try:
|
||||
add_set = set(add_ids) if add_ids else set()
|
||||
remove_set = set(remove_ids) if remove_ids else set()
|
||||
|
||||
if not add_set and not remove_set:
|
||||
return
|
||||
|
||||
# IDs in both sets cancel out — skip them
|
||||
overlap = add_set & remove_set
|
||||
if overlap:
|
||||
add_set -= overlap
|
||||
remove_set -= overlap
|
||||
|
||||
all_ids = sorted(add_set | remove_set)
|
||||
if not all_ids:
|
||||
return
|
||||
|
||||
for server_id in all_ids:
|
||||
if server_id in add_set:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=ServerSquad.current_users + 1)
|
||||
)
|
||||
if server_id in remove_set:
|
||||
await db.execute(
|
||||
update(ServerSquad)
|
||||
.where(ServerSquad.id == server_id)
|
||||
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
if add_set:
|
||||
logger.info('✅ Увеличен счетчик пользователей для серверов', sorted=sorted(add_set))
|
||||
if remove_set:
|
||||
logger.info('✅ Уменьшен счетчик пользователей для серверов', sorted=sorted(remove_set))
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка обновления счетчиков серверов', e=e)
|
||||
raise
|
||||
|
||||
|
||||
async def get_server_ids_by_uuids(db: AsyncSession, squad_uuids: list[str]) -> list[int]:
|
||||
@@ -799,7 +869,7 @@ async def ensure_servers_synced(db: AsyncSession) -> None:
|
||||
server_count = result.scalar() or 0
|
||||
|
||||
if server_count > 0:
|
||||
logger.info(f'✅ В базе уже есть {server_count} серверов, пропускаем синхронизацию')
|
||||
logger.info('✅ В базе уже есть серверов, пропускаем синхронизацию', server_count=server_count)
|
||||
return
|
||||
|
||||
logger.info('🔄 Серверов в БД нет, начинаем синхронизацию с RemnaWave...')
|
||||
@@ -824,10 +894,10 @@ async def ensure_servers_synced(db: AsyncSession) -> None:
|
||||
|
||||
# Синхронизируем
|
||||
created, updated, removed = await sync_with_remnawave(db, squads)
|
||||
logger.info(f'✅ Серверы синхронизированы: +{created} ~{updated} -{removed}')
|
||||
logger.info('✅ Серверы синхронизированы: + ~', created=created, updated=updated, removed=removed)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'❌ Ошибка синхронизации серверов: {e}')
|
||||
logger.error('❌ Ошибка синхронизации серверов', error=e)
|
||||
|
||||
|
||||
async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
@@ -835,7 +905,7 @@ async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
all_servers_result = await db.execute(select(ServerSquad.id, ServerSquad.squad_uuid))
|
||||
all_servers = all_servers_result.fetchall()
|
||||
|
||||
logger.info(f'🔍 Найдено серверов для синхронизации: {len(all_servers)}')
|
||||
logger.info('🔍 Найдено серверов для синхронизации', all_servers_count=len(all_servers))
|
||||
|
||||
updated_count = 0
|
||||
for server_id, squad_uuid in all_servers:
|
||||
@@ -850,16 +920,18 @@ async def sync_server_user_counts(db: AsyncSession) -> int:
|
||||
)
|
||||
actual_users = count_result.scalar() or 0
|
||||
|
||||
logger.info(f'📊 Сервер {server_id} ({squad_uuid[:8]}): {actual_users} пользователей')
|
||||
logger.info(
|
||||
'📊 Сервер пользователей', server_id=server_id, squad_uuid=squad_uuid[:8], actual_users=actual_users
|
||||
)
|
||||
|
||||
await db.execute(update(ServerSquad).where(ServerSquad.id == server_id).values(current_users=actual_users))
|
||||
updated_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'✅ Синхронизированы счетчики для {updated_count} серверов')
|
||||
logger.info('✅ Синхронизированы счетчики для серверов', updated_count=updated_count)
|
||||
return updated_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка синхронизации счетчиков пользователей: {e}')
|
||||
logger.error('Ошибка синхронизации счетчиков пользователей', error=e)
|
||||
await db.rollback()
|
||||
return 0
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import Squad
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_squad_by_uuid(db: AsyncSession, uuid: str) -> Squad | None:
|
||||
@@ -28,7 +27,7 @@ async def create_squad(
|
||||
await db.commit()
|
||||
await db.refresh(squad)
|
||||
|
||||
logger.info(f'✅ Создан сквад: {name}')
|
||||
logger.info('✅ Создан сквад', name=name)
|
||||
return squad
|
||||
|
||||
|
||||
|
||||
+274
-182
@@ -1,11 +1,12 @@
|
||||
import logging
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.notification import clear_notifications
|
||||
@@ -21,7 +22,17 @@ from app.utils.pricing_utils import calculate_months_from_days, get_remaining_mo
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_WEBHOOK_GUARD_SECONDS = 60
|
||||
|
||||
|
||||
def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
|
||||
"""Return True if subscription was updated by webhook within guard window."""
|
||||
if not subscription.last_webhook_update_at:
|
||||
return False
|
||||
elapsed = (datetime.now(UTC).replace(tzinfo=None) - subscription.last_webhook_update_at).total_seconds()
|
||||
return elapsed < _WEBHOOK_GUARD_SECONDS
|
||||
|
||||
|
||||
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
|
||||
@@ -39,7 +50,10 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscri
|
||||
|
||||
if subscription:
|
||||
logger.info(
|
||||
f'🔍 Загружена подписка {subscription.id} для пользователя {user_id}, статус: {subscription.status}'
|
||||
'🔍 Загружена подписка для пользователя статус',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user_id,
|
||||
status=subscription.status,
|
||||
)
|
||||
subscription = await check_and_update_subscription_status(db, subscription)
|
||||
|
||||
@@ -82,19 +96,30 @@ async def create_trial_subscription(
|
||||
if random_squad:
|
||||
final_squads = [random_squad]
|
||||
logger.debug(
|
||||
'Выбран сквад %s для триальной подписки пользователя %s',
|
||||
random_squad,
|
||||
user_id,
|
||||
'Выбран сквад для триальной подписки пользователя', random_squad=random_squad, user_id=user_id
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Не удалось получить сквад для триальной подписки пользователя %s: %s',
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
logger.error('Не удалось получить сквад для триальной подписки пользователя', user_id=user_id, error=error)
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
# Check for existing PENDING trial subscription (retry after failed payment)
|
||||
existing = await get_subscription_by_user_id(db, user_id)
|
||||
if existing and existing.is_trial and existing.status == SubscriptionStatus.PENDING.value:
|
||||
existing.status = SubscriptionStatus.ACTIVE.value
|
||||
existing.start_date = datetime.utcnow()
|
||||
existing.end_date = end_date
|
||||
existing.traffic_limit_gb = traffic_limit_gb
|
||||
existing.device_limit = device_limit
|
||||
existing.connected_squads = final_squads
|
||||
existing.tariff_id = tariff_id
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
logger.info(
|
||||
'🎁 Обновлена PENDING триальная подписка для пользователя', existing_id=existing.id, user_id=user_id
|
||||
)
|
||||
return existing
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
@@ -127,20 +152,14 @@ async def create_trial_subscription(
|
||||
server_ids = await get_server_ids_by_uuids(db, final_squads)
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, server_ids)
|
||||
logger.info(
|
||||
'📈 Обновлен счетчик пользователей для триальных сквадов %s',
|
||||
final_squads,
|
||||
)
|
||||
logger.info('📈 Обновлен счетчик пользователей для триальных сквадов', final_squads=final_squads)
|
||||
else:
|
||||
logger.warning(
|
||||
'⚠️ Не удалось найти серверы для обновления счетчика (сквады %s)',
|
||||
final_squads,
|
||||
)
|
||||
logger.warning('⚠️ Не удалось найти серверы для обновления счетчика (сквады)', final_squads=final_squads)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Ошибка обновления счетчика пользователей для триальных сквадов %s: %s',
|
||||
final_squads,
|
||||
error,
|
||||
'⚠️ Ошибка обновления счетчика пользователей для триальных сквадов',
|
||||
final_squads=final_squads,
|
||||
error=error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
@@ -181,7 +200,10 @@ async def create_paid_subscription(
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
f'💎 Создана платная подписка для пользователя {user_id}, ID: {subscription.id}, статус: {subscription.status}'
|
||||
'💎 Создана платная подписка для пользователя ID: статус',
|
||||
user_id=user_id,
|
||||
subscription_id=subscription.id,
|
||||
status=subscription.status,
|
||||
)
|
||||
|
||||
squad_uuids = list(connected_squads or [])
|
||||
@@ -196,21 +218,21 @@ async def create_paid_subscription(
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, server_ids)
|
||||
logger.info(
|
||||
'📈 Обновлен счетчик пользователей для платной подписки пользователя %s (сквады: %s)',
|
||||
user_id,
|
||||
squad_uuids,
|
||||
'📈 Обновлен счетчик пользователей для платной подписки пользователя (сквады:)',
|
||||
user_id=user_id,
|
||||
squad_uuids=squad_uuids,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя %s (сквады: %s)',
|
||||
user_id,
|
||||
squad_uuids,
|
||||
'⚠️ Не удалось найти серверы для обновления счетчика платной подписки пользователя (сквады:)',
|
||||
user_id=user_id,
|
||||
squad_uuids=squad_uuids,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя %s: %s',
|
||||
user_id,
|
||||
error,
|
||||
'⚠️ Ошибка обновления счетчика пользователей серверов для платной подписки пользователя',
|
||||
user_id=user_id,
|
||||
error=error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
@@ -265,35 +287,34 @@ async def replace_subscription(
|
||||
if update_server_counters:
|
||||
try:
|
||||
from app.database.crud.server_squad import (
|
||||
add_user_to_servers,
|
||||
get_server_ids_by_uuids,
|
||||
remove_user_from_servers,
|
||||
update_server_user_counts,
|
||||
)
|
||||
|
||||
squads_to_remove = old_squads - new_squads
|
||||
squads_to_add = new_squads - old_squads
|
||||
|
||||
if squads_to_remove:
|
||||
server_ids = await get_server_ids_by_uuids(db, list(squads_to_remove))
|
||||
if server_ids:
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
remove_ids = await get_server_ids_by_uuids(db, list(squads_to_remove)) if squads_to_remove else []
|
||||
add_ids = await get_server_ids_by_uuids(db, list(squads_to_add)) if squads_to_add else []
|
||||
|
||||
if squads_to_add:
|
||||
server_ids = await get_server_ids_by_uuids(db, list(squads_to_add))
|
||||
if server_ids:
|
||||
await add_user_to_servers(db, sorted(server_ids))
|
||||
if remove_ids or add_ids:
|
||||
await update_server_user_counts(
|
||||
db,
|
||||
add_ids=add_ids or None,
|
||||
remove_ids=remove_ids or None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s',
|
||||
subscription.id,
|
||||
len(squads_to_remove),
|
||||
len(squads_to_add),
|
||||
'♻️ Обновлены параметры подписки : удалено сквадов , добавлено',
|
||||
subscription_id=subscription.id,
|
||||
squads_to_remove_count=len(squads_to_remove),
|
||||
squads_to_add_count=len(squads_to_add),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Ошибка обновления счетчиков серверов при замене подписки %s: %s',
|
||||
subscription.id,
|
||||
error,
|
||||
'⚠️ Ошибка обновления счетчиков серверов при замене подписки',
|
||||
subscription_id=subscription.id,
|
||||
error=error,
|
||||
)
|
||||
|
||||
return subscription
|
||||
@@ -322,18 +343,20 @@ async def extend_subscription(
|
||||
"""
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(f'🔄 Продление подписки {subscription.id} на {days} дней')
|
||||
logger.info('🔄 Продление подписки на дней', subscription_id=subscription.id, days=days)
|
||||
logger.info(
|
||||
f'📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}, тариф={subscription.tariff_id}'
|
||||
'📊 Текущие параметры: статус=, окончание=, тариф',
|
||||
status=subscription.status,
|
||||
end_date=subscription.end_date,
|
||||
tariff_id=subscription.tariff_id,
|
||||
)
|
||||
|
||||
# Определяем, происходит ли СМЕНА тарифа (а не продление того же)
|
||||
is_tariff_change = (
|
||||
tariff_id is not None and subscription.tariff_id is not None and tariff_id != subscription.tariff_id
|
||||
)
|
||||
# Включает переход из классического режима (tariff_id=None) в тарифный
|
||||
is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id)
|
||||
|
||||
if is_tariff_change:
|
||||
logger.info(f'🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id} → {tariff_id}')
|
||||
logger.info('🔄 Обнаружена СМЕНА тарифа: →', tariff_id=subscription.tariff_id, tariff_id_2=tariff_id)
|
||||
|
||||
# Бонусные дни от триала - добавляются ТОЛЬКО когда подписка истекла
|
||||
# и мы начинаем отсчёт с текущей даты. НЕ начисляются при смене тарифа.
|
||||
@@ -343,9 +366,7 @@ async def extend_subscription(
|
||||
if days < 0:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(
|
||||
'📅 Срок подписки уменьшен на %s дней, новая дата окончания: %s',
|
||||
abs(days),
|
||||
subscription.end_date,
|
||||
'📅 Срок подписки уменьшен на дней, новая дата окончания', abs=abs(days), end_date=subscription.end_date
|
||||
)
|
||||
elif is_tariff_change:
|
||||
# При СМЕНЕ тарифа срок начинается с текущей даты + бонус от триала
|
||||
@@ -355,19 +376,19 @@ async def extend_subscription(
|
||||
if remaining.total_seconds() > 0:
|
||||
bonus_days = max(0, remaining.days)
|
||||
logger.info(
|
||||
'🎁 Обнаружен остаток триала: %s дней для подписки %s',
|
||||
bonus_days,
|
||||
subscription.id,
|
||||
'🎁 Обнаружен остаток триала: дней для подписки',
|
||||
bonus_days=bonus_days,
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
total_days = days + bonus_days
|
||||
subscription.end_date = current_time + timedelta(days=total_days)
|
||||
subscription.start_date = current_time
|
||||
logger.info(f'📅 СМЕНА тарифа: срок начинается с текущей даты + {total_days} дней')
|
||||
logger.info('📅 СМЕНА тарифа: срок начинается с текущей даты + дней', total_days=total_days)
|
||||
elif subscription.end_date > current_time:
|
||||
# Подписка активна - просто добавляем дни к текущей дате окончания
|
||||
# БЕЗ бонусных дней (они уже учтены в end_date)
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
logger.info(f'📅 Подписка активна, добавляем {days} дней к текущей дате окончания')
|
||||
logger.info('📅 Подписка активна, добавляем дней к текущей дате окончания', days=days)
|
||||
else:
|
||||
# Подписка истекла - начинаем с текущей даты + бонус от триала
|
||||
if subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
|
||||
@@ -375,14 +396,19 @@ async def extend_subscription(
|
||||
pass
|
||||
total_days = days + bonus_days
|
||||
subscription.end_date = current_time + timedelta(days=total_days)
|
||||
logger.info(f'📅 Подписка истекла, устанавливаем новую дату окончания на {total_days} дней')
|
||||
logger.info('📅 Подписка истекла, устанавливаем новую дату окончания на дней', total_days=total_days)
|
||||
|
||||
# УДАЛЕНО: Автоматическая конвертация триала по длительности
|
||||
# Теперь триал конвертируется ТОЛЬКО после успешного коммита продления
|
||||
# и ТОЛЬКО вызывающей функцией (например, _auto_extend_subscription)
|
||||
|
||||
# Логируем статус подписки перед проверкой
|
||||
logger.info(f'🔄 Продление подписки {subscription.id}, текущий статус: {subscription.status}, дни: {days}')
|
||||
logger.info(
|
||||
'🔄 Продление подписки текущий статус: дни',
|
||||
subscription_id=subscription.id,
|
||||
status=subscription.status,
|
||||
days=days,
|
||||
)
|
||||
|
||||
if days > 0 and subscription.status in (
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
@@ -391,37 +417,51 @@ async def extend_subscription(
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info(
|
||||
'🔄 Статус подписки %s изменён с %s на ACTIVE',
|
||||
subscription.id,
|
||||
previous_status,
|
||||
'🔄 Статус подписки изменён с на ACTIVE', subscription_id=subscription.id, previous_status=previous_status
|
||||
)
|
||||
elif days > 0 and subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.warning('⚠️ Попытка продлить PENDING подписку %s, дни: %s', subscription.id, days)
|
||||
logger.warning('⚠️ Попытка продлить PENDING подписку , дни', subscription_id=subscription.id, days=days)
|
||||
|
||||
# Обновляем параметры тарифа, если переданы
|
||||
if tariff_id is not None:
|
||||
old_tariff_id = subscription.tariff_id
|
||||
subscription.tariff_id = tariff_id
|
||||
logger.info(f'📦 Обновлен тариф подписки: {old_tariff_id} → {tariff_id}')
|
||||
logger.info('📦 Обновлен тариф подписки: →', old_tariff_id=old_tariff_id, tariff_id=tariff_id)
|
||||
|
||||
# При покупке тарифа сбрасываем триальный статус
|
||||
if subscription.is_trial:
|
||||
subscription.is_trial = False
|
||||
logger.info(f'🎓 Подписка {subscription.id} конвертирована из триала в платную')
|
||||
logger.info('🎓 Подписка конвертирована из триала в платную', subscription_id=subscription.id)
|
||||
|
||||
if traffic_limit_gb is not None:
|
||||
old_traffic = subscription.traffic_limit_gb
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
subscription.traffic_used_gb = 0.0
|
||||
# Сбрасываем все докупки трафика при смене тарифа
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
from app.database.models import TrafficPurchase
|
||||
if is_tariff_change:
|
||||
# При СМЕНЕ тарифа сбрасываем все докупки трафика
|
||||
subscription.traffic_limit_gb = traffic_limit_gb
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
|
||||
logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)')
|
||||
from app.database.models import TrafficPurchase
|
||||
|
||||
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
logger.info(
|
||||
'📊 Обновлен лимит трафика: ГБ → ГБ (смена тарифа, докупки сброшены)',
|
||||
old_traffic=old_traffic,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
)
|
||||
else:
|
||||
# При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик
|
||||
purchased = subscription.purchased_traffic_gb or 0
|
||||
subscription.traffic_limit_gb = traffic_limit_gb + purchased
|
||||
logger.info(
|
||||
'📊 Обновлен лимит трафика: ГБ → ГБ (докупки сохранены: ГБ)',
|
||||
old_traffic=old_traffic,
|
||||
traffic_limit_gb=traffic_limit_gb + purchased,
|
||||
purchased=purchased,
|
||||
)
|
||||
elif settings.RESET_TRAFFIC_ON_PAYMENT:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
# В режиме тарифов сохраняем докупленный трафик при продлении
|
||||
@@ -436,12 +476,12 @@ async def extend_subscription(
|
||||
if device_limit is not None:
|
||||
old_devices = subscription.device_limit
|
||||
subscription.device_limit = device_limit
|
||||
logger.info(f'📱 Обновлен лимит устройств: {old_devices} → {device_limit}')
|
||||
logger.info('📱 Обновлен лимит устройств: →', old_devices=old_devices, device_limit=device_limit)
|
||||
|
||||
if connected_squads is not None:
|
||||
old_squads = subscription.connected_squads
|
||||
subscription.connected_squads = connected_squads
|
||||
logger.info(f'🌍 Обновлены сквады: {old_squads} → {connected_squads}')
|
||||
logger.info('🌍 Обновлены сквады: →', old_squads=old_squads, connected_squads=connected_squads)
|
||||
|
||||
# Обработка daily полей при смене тарифа
|
||||
if is_tariff_change and tariff_id is not None:
|
||||
@@ -475,7 +515,11 @@ async def extend_subscription(
|
||||
subscription.traffic_limit_gb = fixed_limit
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
|
||||
logger.info(f'🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ')
|
||||
logger.info(
|
||||
'🔄 Сброс трафика при продлении (fixed_with_topup): ГБ → ГБ',
|
||||
old_limit=old_limit,
|
||||
fixed_limit=fixed_limit,
|
||||
)
|
||||
|
||||
subscription.updated_at = current_time
|
||||
|
||||
@@ -483,8 +527,8 @@ async def extend_subscription(
|
||||
await db.refresh(subscription)
|
||||
await clear_notifications(db, subscription.id)
|
||||
|
||||
logger.info(f'✅ Подписка продлена до: {subscription.end_date}')
|
||||
logger.info(f'📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}')
|
||||
logger.info('✅ Подписка продлена до', end_date=subscription.end_date)
|
||||
logger.info('📊 Новые параметры: статус=, окончание', status=subscription.status, end_date=subscription.end_date)
|
||||
|
||||
return subscription
|
||||
|
||||
@@ -531,7 +575,10 @@ async def add_subscription_traffic(db: AsyncSession, subscription: Subscription,
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
f'📈 К подписке пользователя {subscription.user_id} добавлено {gb} ГБ трафика (истекает {new_expires_at.strftime("%d.%m.%Y")})'
|
||||
'📈 К подписке пользователя добавлено ГБ трафика (истекает )',
|
||||
user_id=subscription.user_id,
|
||||
gb=gb,
|
||||
new_expires_at=new_expires_at.strftime('%d.%m.%Y'),
|
||||
)
|
||||
return subscription
|
||||
|
||||
@@ -543,7 +590,7 @@ async def add_subscription_devices(db: AsyncSession, subscription: Subscription,
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'📱 К подписке пользователя {subscription.user_id} добавлено {devices} устройств')
|
||||
logger.info('📱 К подписке пользователя добавлено устройств', user_id=subscription.user_id, devices=devices)
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -555,7 +602,7 @@ async def add_subscription_squad(db: AsyncSession, subscription: Subscription, s
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'🌍 К подписке пользователя {subscription.user_id} добавлен сквад {squad_uuid}')
|
||||
logger.info('🌍 К подписке пользователя добавлен сквад', user_id=subscription.user_id, squad_uuid=squad_uuid)
|
||||
|
||||
return subscription
|
||||
|
||||
@@ -570,7 +617,7 @@ async def remove_subscription_squad(db: AsyncSession, subscription: Subscription
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'🚫 Из подписки пользователя {subscription.user_id} удален сквад {squad_uuid}')
|
||||
logger.info('🚫 Из подписки пользователя удален сквад', user_id=subscription.user_id, squad_uuid=squad_uuid)
|
||||
|
||||
return subscription
|
||||
|
||||
@@ -586,6 +633,9 @@ async def decrement_subscription_server_counts(
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
# Save ID before any DB operations that might invalidate the ORM object
|
||||
sub_id = subscription.id
|
||||
|
||||
server_ids: set[int] = set()
|
||||
|
||||
if subscription_servers is not None:
|
||||
@@ -594,14 +644,10 @@ async def decrement_subscription_server_counts(
|
||||
server_ids.add(sub_server.server_squad_id)
|
||||
else:
|
||||
try:
|
||||
ids_from_links = await get_subscription_server_ids(db, subscription.id)
|
||||
ids_from_links = await get_subscription_server_ids(db, sub_id)
|
||||
server_ids.update(ids_from_links)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s',
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
logger.error('⚠️ Не удалось получить серверы подписки для уменьшения счетчика', sub_id=sub_id, error=error)
|
||||
|
||||
connected_squads = list(subscription.connected_squads or [])
|
||||
if connected_squads:
|
||||
@@ -611,11 +657,7 @@ async def decrement_subscription_server_counts(
|
||||
squad_server_ids = await get_server_ids_by_uuids(db, connected_squads)
|
||||
server_ids.update(squad_server_ids)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s',
|
||||
subscription.id,
|
||||
error,
|
||||
)
|
||||
logger.error('⚠️ Не удалось сопоставить сквады подписки с серверами', sub_id=sub_id, error=error)
|
||||
|
||||
if not server_ids:
|
||||
return
|
||||
@@ -623,13 +665,21 @@ async def decrement_subscription_server_counts(
|
||||
try:
|
||||
from app.database.crud.server_squad import remove_user_from_servers
|
||||
|
||||
await remove_user_from_servers(db, sorted(server_ids))
|
||||
# Use savepoint so StaleDataError rollback doesn't affect the parent transaction
|
||||
async with db.begin_nested():
|
||||
await remove_user_from_servers(db, list(server_ids))
|
||||
except StaleDataError:
|
||||
logger.warning(
|
||||
'⚠️ Подписка уже удалена (StaleDataError), пропускаем декремент серверов',
|
||||
sub_id=sub_id,
|
||||
list=list(server_ids),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s',
|
||||
list(server_ids),
|
||||
subscription.id,
|
||||
error,
|
||||
'⚠️ Ошибка уменьшения счетчика пользователей серверов для подписки',
|
||||
list=list(server_ids),
|
||||
sub_id=sub_id,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
@@ -644,7 +694,7 @@ async def update_subscription_autopay(
|
||||
await db.refresh(subscription)
|
||||
|
||||
status = 'включен' if enabled else 'выключен'
|
||||
logger.info(f'💳 Автоплатеж для подписки пользователя {subscription.user_id} {status}')
|
||||
logger.info('💳 Автоплатеж для подписки пользователя', user_id=subscription.user_id, status=status)
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -655,7 +705,7 @@ async def deactivate_subscription(db: AsyncSession, subscription: Subscription)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'❌ Подписка пользователя {subscription.user_id} деактивирована')
|
||||
logger.info('❌ Подписка пользователя деактивирована', user_id=subscription.user_id)
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -794,9 +844,9 @@ async def get_subscriptions_statistics(db: AsyncSession) -> dict:
|
||||
renewals_count = conversion_stats.get('month_conversions', 0)
|
||||
|
||||
logger.info('📊 Статистика конверсии из таблицы conversions:')
|
||||
logger.info(f' Общее количество конверсий: {conversion_stats.get("total_conversions", 0)}')
|
||||
logger.info(f' Процент конверсии: {trial_to_paid_conversion}%')
|
||||
logger.info(f' Конверсий за месяц: {renewals_count}')
|
||||
logger.info('Общее количество конверсий', get=conversion_stats.get('total_conversions', 0))
|
||||
logger.info('Процент конверсии', trial_to_paid_conversion=trial_to_paid_conversion)
|
||||
logger.info('Конверсий за месяц', renewals_count=renewals_count)
|
||||
|
||||
except ImportError:
|
||||
logger.warning('⚠️ Таблица subscription_conversions не найдена, используем старую логику')
|
||||
@@ -893,9 +943,7 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) ->
|
||||
)
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
'Не удалось обновить счётчики серверов при сбросе триала %s: %s',
|
||||
subscription.id,
|
||||
error,
|
||||
'Не удалось обновить счётчики серверов при сбросе триала', subscription_id=subscription.id, error=error
|
||||
)
|
||||
|
||||
subscription_ids = [subscription.id for subscription in subscriptions]
|
||||
@@ -904,11 +952,7 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) ->
|
||||
try:
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id.in_(subscription_ids)))
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
logger.error(
|
||||
'Ошибка удаления серверных связей триалов %s: %s',
|
||||
subscription_ids,
|
||||
error,
|
||||
)
|
||||
logger.error('Ошибка удаления серверных связей триалов', subscription_ids=subscription_ids, error=error)
|
||||
raise
|
||||
|
||||
await db.execute(delete(Subscription).where(Subscription.id.in_(subscription_ids)))
|
||||
@@ -917,10 +961,10 @@ async def reset_trials_for_users_without_paid_subscription(db: AsyncSession) ->
|
||||
await db.commit()
|
||||
except Exception as error: # pragma: no cover - defensive logging
|
||||
await db.rollback()
|
||||
logger.error('Ошибка сохранения сброса триалов: %s', error)
|
||||
logger.error('Ошибка сохранения сброса триалов', error=error)
|
||||
raise
|
||||
|
||||
logger.info('♻️ Сброшено триальных подписок: %s', reset_count)
|
||||
logger.info('♻️ Сброшено триальных подписок', reset_count=reset_count)
|
||||
return reset_count
|
||||
|
||||
|
||||
@@ -997,7 +1041,12 @@ async def add_subscription_servers(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'🌐 К подписке {subscription.id} добавлено {len(server_squad_ids)} серверов с ценами: {paid_prices}')
|
||||
logger.info(
|
||||
'🌐 К подписке добавлено серверов с ценами',
|
||||
subscription_id=subscription.id,
|
||||
server_squad_ids_count=len(server_squad_ids),
|
||||
paid_prices=paid_prices,
|
||||
)
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -1034,7 +1083,7 @@ async def get_servers_monthly_prices(
|
||||
user_promo_group = user.get_primary_promo_group()
|
||||
user_promo_group_id = user_promo_group.id if user_promo_group else None
|
||||
except Exception as e:
|
||||
logger.warning(f'Не удалось получить промогруппу пользователя: {e}')
|
||||
logger.warning('Не удалось получить промогруппу пользователя', error=e)
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
# Загружаем сервер с промогруппами
|
||||
@@ -1060,9 +1109,11 @@ async def get_servers_monthly_prices(
|
||||
else:
|
||||
# Сервер недоступен для промогруппы пользователя
|
||||
logger.warning(
|
||||
f'⚠️ Сервер {server.display_name} (id={server_id}) недоступен для '
|
||||
f'промогруппы пользователя (promo_group_id={user_promo_group_id}), '
|
||||
f'allowed_promo_groups={[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else []}'
|
||||
'⚠️ Сервер (id=) недоступен для промогруппы пользователя (promo_group_id=), allowed_promo_groups',
|
||||
display_name=server.display_name,
|
||||
server_id=server_id,
|
||||
user_promo_group_id=user_promo_group_id,
|
||||
value=[pg.id for pg in server.allowed_promo_groups] if server.allowed_promo_groups else [],
|
||||
)
|
||||
prices.append(server.price_kopeks) # Всё равно берём реальную цену
|
||||
|
||||
@@ -1177,8 +1228,10 @@ async def calculate_subscription_total_cost(
|
||||
],
|
||||
}
|
||||
|
||||
logger.debug(f'📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):')
|
||||
logger.debug(f' Базовый период: {base_price / 100}₽')
|
||||
logger.debug(
|
||||
'📊 Расчет стоимости подписки на дней ( мес)', period_days=period_days, months_in_period=months_in_period
|
||||
)
|
||||
logger.debug('Базовый период: ₽', base_price=base_price / 100)
|
||||
if total_traffic_price > 0:
|
||||
message = f' Трафик: {traffic_price_per_month / 100}₽/мес × {months_in_period} = {total_traffic_price / 100}₽'
|
||||
if total_traffic_discount > 0:
|
||||
@@ -1198,7 +1251,7 @@ async def calculate_subscription_total_cost(
|
||||
if total_devices_discount > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
|
||||
logger.debug(message)
|
||||
logger.debug(f' ИТОГО: {total_cost / 100}₽')
|
||||
logger.debug('ИТОГО: ₽', total_cost=total_cost / 100)
|
||||
|
||||
return total_cost, details
|
||||
|
||||
@@ -1250,11 +1303,11 @@ async def remove_subscription_servers(db: AsyncSession, subscription_id: int, se
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'🗑️ Удалены серверы {server_squad_ids} из подписки {subscription_id}')
|
||||
logger.info('🗑️ Удалены серверы из подписки', server_squad_ids=server_squad_ids, subscription_id=subscription_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка удаления серверов из подписки: {e}')
|
||||
logger.error('Ошибка удаления серверов из подписки', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
@@ -1345,8 +1398,13 @@ async def get_subscription_renewal_cost(
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
logger.info(f'💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):')
|
||||
logger.info(f' 📅 Период: {base_price / 100}₽')
|
||||
logger.info(
|
||||
'💰 Расчет продления подписки на дней ( мес)',
|
||||
subscription_id=subscription_id,
|
||||
period_days=period_days,
|
||||
months_in_period=months_in_period,
|
||||
)
|
||||
logger.info('📅 Период: ₽', base_price=base_price / 100)
|
||||
if total_servers_cost > 0:
|
||||
message = f' 🌍 Серверы: {servers_price_per_month / 100}₽/мес × {months_in_period} = {total_servers_cost / 100}₽'
|
||||
if total_servers_discount > 0:
|
||||
@@ -1364,12 +1422,12 @@ async def get_subscription_renewal_cost(
|
||||
if total_devices_discount > 0:
|
||||
message += f' (скидка {devices_discount_percent}%: -{total_devices_discount / 100}₽)'
|
||||
logger.info(message)
|
||||
logger.info(f' 💎 ИТОГО: {total_cost / 100}₽')
|
||||
logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100)
|
||||
|
||||
return total_cost
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка расчета стоимости продления: {e}')
|
||||
logger.error('Ошибка расчета стоимости продления', error=e)
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
return PERIOD_PRICES.get(period_days, 0)
|
||||
@@ -1458,7 +1516,7 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info(f'💰 Итого доплата за {months_to_pay} мес: {total_cost / 100}₽')
|
||||
logger.info('💰 Итого доплата за мес: ₽', months_to_pay=months_to_pay, total_cost=total_cost / 100)
|
||||
return total_cost
|
||||
|
||||
|
||||
@@ -1469,7 +1527,7 @@ async def expire_subscription(db: AsyncSession, subscription: Subscription) -> S
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'⏰ Подписка пользователя {subscription.user_id} помечена как истёкшая')
|
||||
logger.info('⏰ Подписка пользователя помечена как истёкшая', user_id=subscription.user_id)
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -1477,28 +1535,30 @@ async def check_and_update_subscription_status(db: AsyncSession, subscription: S
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
logger.info(
|
||||
'🔍 Проверка статуса подписки %s, текущий статус: %s, дата окончания: %s, текущее время: %s',
|
||||
subscription.id,
|
||||
subscription.status,
|
||||
format_local_datetime(subscription.end_date),
|
||||
format_local_datetime(current_time),
|
||||
'🔍 Проверка статуса подписки , текущий статус дата окончания текущее время',
|
||||
subscription_id=subscription.id,
|
||||
subscription_status=subscription.status,
|
||||
format_local_datetime=format_local_datetime(subscription.end_date),
|
||||
format_local_datetime_2=format_local_datetime(current_time),
|
||||
)
|
||||
|
||||
# Для суточных тарифов с паузой не меняем статус на expired
|
||||
# (время "заморожено" пока пользователь на паузе)
|
||||
is_daily_paused = getattr(subscription, 'is_daily_paused', False)
|
||||
if is_daily_paused:
|
||||
logger.info(f'⏸️ Суточная подписка {subscription.id} на паузе, пропускаем проверку истечения')
|
||||
logger.info('⏸️ Суточная подписка на паузе, пропускаем проверку истечения', subscription_id=subscription.id)
|
||||
return subscription
|
||||
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date <= current_time:
|
||||
# Детальное логирование для отладки проблемы с деактивацией
|
||||
time_diff = current_time - subscription.end_date
|
||||
logger.warning(
|
||||
f'⏰ DEACTIVATION: подписка {subscription.id} (user_id={subscription.user_id}) '
|
||||
f'деактивируется в check_and_update_subscription_status. '
|
||||
f'end_date={subscription.end_date}, current_time={current_time}, '
|
||||
f'просрочена на {time_diff}'
|
||||
'⏰ DEACTIVATION: подписка (user_id=) деактивируется в check_and_update_subscription_status. end_date=, current_time=, просрочена на',
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
end_date=subscription.end_date,
|
||||
current_time=current_time,
|
||||
time_diff=time_diff,
|
||||
)
|
||||
|
||||
subscription.status = SubscriptionStatus.EXPIRED.value
|
||||
@@ -1507,9 +1567,9 @@ async def check_and_update_subscription_status(db: AsyncSession, subscription: S
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f"⏰ Статус подписки пользователя {subscription.user_id} изменен на 'expired'")
|
||||
logger.info("⏰ Статус подписки пользователя изменен на 'expired'", user_id=subscription.user_id)
|
||||
elif subscription.status == SubscriptionStatus.PENDING.value:
|
||||
logger.info(f'ℹ️ Проверка PENDING подписки {subscription.id}, статус остается без изменений')
|
||||
logger.info('ℹ️ Проверка PENDING подписки статус остается без изменений', subscription_id=subscription.id)
|
||||
|
||||
return subscription
|
||||
|
||||
@@ -1564,7 +1624,7 @@ async def create_subscription_no_commit(
|
||||
await db.flush()
|
||||
|
||||
# Не коммитим сразу, оставляем для пакетной обработки
|
||||
logger.info(f'✅ Подготовлена подписка для пользователя {user_id} (ожидает коммита)')
|
||||
logger.info('✅ Подготовлена подписка для пользователя (ожидает коммита)', user_id=user_id)
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -1612,7 +1672,7 @@ async def create_subscription(
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'✅ Создана подписка для пользователя {user_id}')
|
||||
logger.info('✅ Создана подписка для пользователя', user_id=user_id)
|
||||
return subscription
|
||||
|
||||
|
||||
@@ -1644,9 +1704,9 @@ async def create_pending_subscription(
|
||||
and existing_subscription.end_date > current_time
|
||||
):
|
||||
logger.warning(
|
||||
'⚠️ Попытка создать pending %sподписку для активного пользователя %s. Возвращаем существующую запись.',
|
||||
trial_label,
|
||||
user_id,
|
||||
'⚠️ Попытка создать pending подписку для активного пользователя . Возвращаем существующую запись.',
|
||||
trial_label=trial_label,
|
||||
user_id=user_id,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
@@ -1664,11 +1724,11 @@ async def create_pending_subscription(
|
||||
await db.refresh(existing_subscription)
|
||||
|
||||
logger.info(
|
||||
'♻️ Обновлена ожидающая %sподписка пользователя %s, ID: %s, метод оплаты: %s',
|
||||
trial_label,
|
||||
user_id,
|
||||
existing_subscription.id,
|
||||
payment_method,
|
||||
'♻️ Обновлена ожидающая подписка пользователя , ID метод оплаты',
|
||||
trial_label=trial_label,
|
||||
user_id=user_id,
|
||||
existing_subscription_id=existing_subscription.id,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
return existing_subscription
|
||||
|
||||
@@ -1690,11 +1750,11 @@ async def create_pending_subscription(
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
'💳 Создана ожидающая %sподписка для пользователя %s, ID: %s, метод оплаты: %s',
|
||||
trial_label,
|
||||
user_id,
|
||||
subscription.id,
|
||||
payment_method,
|
||||
'💳 Создана ожидающая подписка для пользователя , ID метод оплаты',
|
||||
trial_label=trial_label,
|
||||
user_id=user_id,
|
||||
subscription_id=subscription.id,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
return subscription
|
||||
@@ -1727,7 +1787,7 @@ async def create_pending_trial_subscription(
|
||||
|
||||
async def activate_pending_subscription(db: AsyncSession, user_id: int, period_days: int = None) -> Subscription | None:
|
||||
"""Активирует pending подписку пользователя, меняя её статус на ACTIVE."""
|
||||
logger.info(f'Активация pending подписки: пользователь {user_id}, период {period_days} дней')
|
||||
logger.info('Активация pending подписки: пользователь период дней', user_id=user_id, period_days=period_days)
|
||||
|
||||
# Находим pending подписку пользователя
|
||||
result = await db.execute(
|
||||
@@ -1738,11 +1798,14 @@ async def activate_pending_subscription(db: AsyncSession, user_id: int, period_d
|
||||
pending_subscription = result.scalar_one_or_none()
|
||||
|
||||
if not pending_subscription:
|
||||
logger.warning(f'Не найдена pending подписка для пользователя {user_id}')
|
||||
logger.warning('Не найдена pending подписка для пользователя', user_id=user_id)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f'Найдена pending подписка {pending_subscription.id} для пользователя {user_id}, статус: {pending_subscription.status}'
|
||||
'Найдена pending подписка для пользователя статус',
|
||||
pending_subscription_id=pending_subscription.id,
|
||||
user_id=user_id,
|
||||
status=pending_subscription.status,
|
||||
)
|
||||
|
||||
# Обновляем статус подписки на ACTIVE
|
||||
@@ -1762,7 +1825,9 @@ async def activate_pending_subscription(db: AsyncSession, user_id: int, period_d
|
||||
await db.commit()
|
||||
await db.refresh(pending_subscription)
|
||||
|
||||
logger.info(f'Подписка пользователя {user_id} активирована, ID: {pending_subscription.id}')
|
||||
logger.info(
|
||||
'Подписка пользователя активирована, ID', user_id=user_id, pending_subscription_id=pending_subscription.id
|
||||
)
|
||||
|
||||
return pending_subscription
|
||||
|
||||
@@ -1773,7 +1838,11 @@ async def activate_pending_trial_subscription(
|
||||
user_id: int,
|
||||
) -> Subscription | None:
|
||||
"""Активирует pending триальную подписку по её ID после оплаты."""
|
||||
logger.info(f'Активация pending триальной подписки: subscription_id={subscription_id}, user_id={user_id}')
|
||||
logger.info(
|
||||
'Активация pending триальной подписки: subscription_id=, user_id',
|
||||
subscription_id=subscription_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Находим pending подписку по ID
|
||||
result = await db.execute(
|
||||
@@ -1789,10 +1858,16 @@ async def activate_pending_trial_subscription(
|
||||
pending_subscription = result.scalar_one_or_none()
|
||||
|
||||
if not pending_subscription:
|
||||
logger.warning(f'Не найдена pending триальная подписка {subscription_id} для пользователя {user_id}')
|
||||
logger.warning(
|
||||
'Не найдена pending триальная подписка для пользователя', subscription_id=subscription_id, user_id=user_id
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(f'Найдена pending триальная подписка {pending_subscription.id}, статус: {pending_subscription.status}')
|
||||
logger.info(
|
||||
'Найдена pending триальная подписка статус',
|
||||
pending_subscription_id=pending_subscription.id,
|
||||
status=pending_subscription.status,
|
||||
)
|
||||
|
||||
# Обновляем статус подписки на ACTIVE
|
||||
current_time = datetime.utcnow()
|
||||
@@ -1815,7 +1890,11 @@ async def activate_pending_trial_subscription(
|
||||
await db.commit()
|
||||
await db.refresh(pending_subscription)
|
||||
|
||||
logger.info(f'Триальная подписка {pending_subscription.id} активирована для пользователя {user_id}')
|
||||
logger.info(
|
||||
'Триальная подписка активирована для пользователя',
|
||||
pending_subscription_id=pending_subscription.id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return pending_subscription
|
||||
|
||||
@@ -1861,7 +1940,7 @@ async def get_daily_subscriptions_for_charge(db: AsyncSession) -> list[Subscript
|
||||
result = await db.execute(query)
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
logger.info(f'🔍 Найдено {len(subscriptions)} суточных подписок для списания')
|
||||
logger.info('🔍 Найдено суточных подписок для списания', subscriptions_count=len(subscriptions))
|
||||
|
||||
return list(subscriptions)
|
||||
|
||||
@@ -1898,7 +1977,7 @@ async def get_disabled_daily_subscriptions_for_resume(
|
||||
result = await db.execute(query)
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
logger.info(f'🔍 Найдено {len(subscriptions)} DISABLED суточных подписок для возобновления')
|
||||
logger.info('🔍 Найдено DISABLED суточных подписок для возобновления', subscriptions_count=len(subscriptions))
|
||||
|
||||
return list(subscriptions)
|
||||
|
||||
@@ -1909,14 +1988,18 @@ async def pause_daily_subscription(
|
||||
) -> Subscription:
|
||||
"""Приостанавливает суточную подписку (списание не будет происходить)."""
|
||||
if not subscription.is_daily_tariff:
|
||||
logger.warning(f'Попытка приостановить не-суточную подписку {subscription.id}')
|
||||
logger.warning('Попытка приостановить не-суточную подписку', subscription_id=subscription.id)
|
||||
return subscription
|
||||
|
||||
subscription.is_daily_paused = True
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'⏸️ Суточная подписка {subscription.id} приостановлена пользователем {subscription.user_id}')
|
||||
logger.info(
|
||||
'⏸️ Суточная подписка приостановлена пользователем',
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
@@ -1927,23 +2010,30 @@ async def resume_daily_subscription(
|
||||
) -> Subscription:
|
||||
"""Возобновляет суточную подписку (списание продолжится)."""
|
||||
if not subscription.is_daily_tariff:
|
||||
logger.warning(f'Попытка возобновить не-суточную подписку {subscription.id}')
|
||||
logger.warning('Попытка возобновить не-суточную подписку', subscription_id=subscription.id)
|
||||
return subscription
|
||||
|
||||
subscription.is_daily_paused = False
|
||||
|
||||
# Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
|
||||
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
|
||||
previous_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
# Обновляем время последнего списания для корректного расчёта следующего
|
||||
subscription.last_daily_charge_at = datetime.utcnow()
|
||||
subscription.end_date = datetime.utcnow() + timedelta(days=1)
|
||||
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE')
|
||||
logger.info(
|
||||
'✅ Суточная подписка восстановлена из в ACTIVE',
|
||||
subscription_id=subscription.id,
|
||||
previous_status=previous_status,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(f'▶️ Суточная подписка {subscription.id} возобновлена пользователем {subscription.user_id}')
|
||||
logger.info(
|
||||
'▶️ Суточная подписка возобновлена пользователем', subscription_id=subscription.id, user_id=subscription.user_id
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
@@ -1961,7 +2051,7 @@ async def update_daily_charge_time(
|
||||
new_end_date = now + timedelta(days=1)
|
||||
if subscription.end_date is None or subscription.end_date < new_end_date:
|
||||
subscription.end_date = new_end_date
|
||||
logger.info(f'📅 Продлена подписка {subscription.id} до {new_end_date}')
|
||||
logger.info('📅 Продлена подписка до', subscription_id=subscription.id, new_end_date=new_end_date)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
@@ -1982,7 +2072,9 @@ async def suspend_daily_subscription_insufficient_balance(
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
f'⚠️ Суточная подписка {subscription.id} приостановлена: недостаточно средств (user_id={subscription.user_id})'
|
||||
'⚠️ Суточная подписка приостановлена: недостаточно средств (user_id=)',
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import SubscriptionConversion, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_subscription_conversion(
|
||||
@@ -32,7 +32,11 @@ async def create_subscription_conversion(
|
||||
await db.refresh(conversion)
|
||||
|
||||
logger.info(
|
||||
f'✅ Создана запись о конверсии для пользователя {user_id}: {trial_duration_days} дн. → {first_paid_period_days} дн. за {first_payment_amount_kopeks / 100}₽'
|
||||
'✅ Создана запись о конверсии для пользователя дн. → дн. за ₽',
|
||||
user_id=user_id,
|
||||
trial_duration_days=trial_duration_days,
|
||||
first_paid_period_days=first_paid_period_days,
|
||||
first_payment_amount_kopeks=first_payment_amount_kopeks / 100,
|
||||
)
|
||||
|
||||
return conversion
|
||||
@@ -85,9 +89,9 @@ async def get_conversion_statistics(db: AsyncSession) -> dict:
|
||||
month_conversions = month_conversions_result.scalar() or 0
|
||||
|
||||
logger.info('📊 Статистика конверсий:')
|
||||
logger.info(f' Всего пользователей с подписками: {total_users_with_subscriptions}')
|
||||
logger.info(f' Оплативших подписку: {users_with_paid}')
|
||||
logger.info(f' Рассчитанная конверсия: {conversion_rate}%')
|
||||
logger.info('Всего пользователей с подписками', total_users_with_subscriptions=total_users_with_subscriptions)
|
||||
logger.info('Оплативших подписку', users_with_paid=users_with_paid)
|
||||
logger.info('Рассчитанная конверсия', conversion_rate=conversion_rate)
|
||||
|
||||
return {
|
||||
'total_conversions': total_conversions,
|
||||
|
||||
+45
-48
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -7,7 +6,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import PromoGroup, Subscription, Tariff
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _normalize_period_prices(period_prices: dict[int, int] | None) -> dict[str, int]:
|
||||
@@ -241,13 +240,13 @@ async def create_tariff(
|
||||
await db.refresh(tariff)
|
||||
|
||||
logger.info(
|
||||
"Создан тариф '%s' (id=%s, tier=%s, traffic=%sGB, devices=%s, prices=%s)",
|
||||
tariff.name,
|
||||
tariff.id,
|
||||
tariff.tier_level,
|
||||
tariff.traffic_limit_gb,
|
||||
tariff.device_limit,
|
||||
normalized_prices,
|
||||
"Создан тариф '' (id tier traffic=GB, devices prices=)",
|
||||
tariff_name=tariff.name,
|
||||
tariff_id=tariff.id,
|
||||
tier_level=tariff.tier_level,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
normalized_prices=normalized_prices,
|
||||
)
|
||||
|
||||
return tariff
|
||||
@@ -365,11 +364,7 @@ async def update_tariff(
|
||||
await db.commit()
|
||||
await db.refresh(tariff)
|
||||
|
||||
logger.info(
|
||||
"Обновлен тариф '%s' (id=%s)",
|
||||
tariff.name,
|
||||
tariff.id,
|
||||
)
|
||||
logger.info("Обновлен тариф '' (id=)", tariff_name=tariff.name, tariff_id=tariff.id)
|
||||
|
||||
return tariff
|
||||
|
||||
@@ -393,10 +388,10 @@ async def delete_tariff(db: AsyncSession, tariff: Tariff) -> bool:
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Удален тариф '%s' (id=%s), затронуто подписок: %s",
|
||||
tariff_name,
|
||||
tariff_id,
|
||||
affected_subscriptions,
|
||||
"Удален тариф '' (id=), затронуто подписок",
|
||||
tariff_name=tariff_name,
|
||||
tariff_id=tariff_id,
|
||||
affected_subscriptions=affected_subscriptions,
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -486,16 +481,14 @@ async def reorder_tariffs(
|
||||
for order, tariff_id in enumerate(tariff_order):
|
||||
await db.execute(update(Tariff).where(Tariff.id == tariff_id).values(display_order=order))
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info('Изменен порядок тарифов: %s', tariff_order)
|
||||
logger.info('Изменен порядок тарифов', tariff_order=tariff_order)
|
||||
|
||||
|
||||
async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
|
||||
"""
|
||||
Синхронизирует дефолтный тариф из конфига (.env) в БД.
|
||||
Создаёт тариф "Стандартный" если в БД нет тарифов.
|
||||
Обновляет цены существующего тарифа если он есть.
|
||||
Создаёт тариф "Стандартный" только если в БД нет тарифов.
|
||||
Существующий тариф НЕ перезаписывается — админ управляет им через кабинет.
|
||||
|
||||
Returns:
|
||||
Tariff или None если не требуется синхронизация
|
||||
@@ -521,13 +514,12 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
|
||||
existing_tariff = result.scalar_one_or_none()
|
||||
|
||||
if existing_tariff:
|
||||
# Обновляем цены существующего тарифа
|
||||
existing_tariff.period_prices = period_prices
|
||||
existing_tariff.traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
existing_tariff.device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
await db.commit()
|
||||
await db.refresh(existing_tariff)
|
||||
logger.info("Обновлён дефолтный тариф 'Стандартный' из конфига")
|
||||
# Тариф уже существует — НЕ перезаписываем настройки из конфига.
|
||||
# Админ управляет тарифом через кабинет, синхронизация не нужна.
|
||||
logger.info(
|
||||
"Дефолтный тариф 'Стандартный' (id=) уже существует, пропускаем sync из конфига",
|
||||
existing_tariff_id=existing_tariff.id,
|
||||
)
|
||||
return existing_tariff
|
||||
|
||||
if tariff_count == 0:
|
||||
@@ -548,7 +540,7 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
|
||||
db.add(new_tariff)
|
||||
await db.commit()
|
||||
await db.refresh(new_tariff)
|
||||
logger.info("Создан дефолтный тариф 'Стандартный' из конфига: %s", period_prices)
|
||||
logger.info("Создан дефолтный тариф 'Стандартный' из конфига", period_prices=period_prices)
|
||||
return new_tariff
|
||||
|
||||
return None
|
||||
@@ -573,24 +565,29 @@ async def load_period_prices_from_db(db: AsyncSession) -> None:
|
||||
)
|
||||
tariff = result.scalar_one_or_none()
|
||||
|
||||
if tariff and tariff.period_prices:
|
||||
# Преобразуем строковые ключи в int
|
||||
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
|
||||
|
||||
if period_prices:
|
||||
set_period_prices_from_db(period_prices)
|
||||
logger.info(
|
||||
"Загружены периоды из тарифа '%s': %s",
|
||||
tariff.name,
|
||||
{f'{d}д': f'{p // 100}₽' for d, p in period_prices.items()},
|
||||
)
|
||||
else:
|
||||
logger.warning("Тариф '%s' не имеет активных периодов", tariff.name)
|
||||
else:
|
||||
if not tariff:
|
||||
logger.info('Активные тарифы не найдены, используются цены из .env')
|
||||
return
|
||||
|
||||
if not tariff.period_prices:
|
||||
logger.warning("Тариф '' (id=) найден, но period_prices пуст", tariff_name=tariff.name, tariff_id=tariff.id)
|
||||
return
|
||||
|
||||
# Преобразуем строковые ключи в int
|
||||
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
|
||||
|
||||
if period_prices:
|
||||
set_period_prices_from_db(period_prices)
|
||||
logger.info(
|
||||
"Загружены периоды из тарифа '%s': %s",
|
||||
tariff.name,
|
||||
{f'{d}д': f'{p // 100}₽' for d, p in period_prices.items()},
|
||||
)
|
||||
else:
|
||||
logger.warning("Тариф '' не имеет активных периодов (все цены = 0)", tariff_name=tariff.name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка загрузки периодов из БД: %s', e)
|
||||
logger.error('Ошибка загрузки периодов из БД', e=e)
|
||||
|
||||
|
||||
async def ensure_tariffs_synced(db: AsyncSession) -> None:
|
||||
@@ -603,4 +600,4 @@ async def ensure_tariffs_synced(db: AsyncSession) -> None:
|
||||
# Загружаем периоды из БД в PERIOD_PRICES
|
||||
await load_period_prices_from_db(db)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка синхронизации тарифов: %s', e)
|
||||
logger.error('Ошибка синхронизации тарифов', e=e)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, desc, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import SupportAuditLog, Ticket, TicketMessage, TicketStatus
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class TicketCRUD:
|
||||
@@ -64,7 +64,7 @@ class TicketCRUD:
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit ticket.created event: %s', error)
|
||||
logger.warning('Failed to emit ticket.created event', error=error)
|
||||
|
||||
return ticket
|
||||
|
||||
@@ -248,7 +248,7 @@ class TicketCRUD:
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit ticket.status_changed event: %s', error)
|
||||
logger.warning('Failed to emit ticket.status_changed event', error=error)
|
||||
|
||||
return True
|
||||
|
||||
@@ -440,7 +440,7 @@ class TicketMessageCRUD:
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit ticket.message_added event: %s', error)
|
||||
logger.warning('Failed to emit ticket.message_added event', error=error)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""CRUD operations for TicketNotification."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import desc, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import Ticket, TicketNotification
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class TicketNotificationCRUD:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import PaymentMethod, Transaction, TransactionType, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Реальные платёжные методы для подсчёта дохода
|
||||
# Исключены: MANUAL (админские), BALANCE (оплата с баланса), NULL (колесо, промокоды, бонусы)
|
||||
@@ -37,6 +37,7 @@ async def create_transaction(
|
||||
payment_method: PaymentMethod | None = None,
|
||||
external_id: str | None = None,
|
||||
is_completed: bool = True,
|
||||
created_at: datetime | None = None,
|
||||
) -> Transaction:
|
||||
transaction = Transaction(
|
||||
user_id=user_id,
|
||||
@@ -47,13 +48,19 @@ async def create_transaction(
|
||||
external_id=external_id,
|
||||
is_completed=is_completed,
|
||||
completed_at=datetime.utcnow() if is_completed else None,
|
||||
**({'created_at': created_at} if created_at else {}),
|
||||
)
|
||||
|
||||
db.add(transaction)
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f'💳 Создана транзакция: {type.value} на {amount_kopeks / 100}₽ для пользователя {user_id}')
|
||||
logger.info(
|
||||
'💳 Создана транзакция: на ₽ для пользователя',
|
||||
type_value=type.value,
|
||||
amount_kopeks=amount_kopeks / 100,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Отправляем событие о транзакции
|
||||
try:
|
||||
@@ -75,7 +82,7 @@ async def create_transaction(
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit transaction event: %s', error)
|
||||
logger.warning('Failed to emit transaction event', error=error)
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
@@ -84,11 +91,7 @@ async def create_transaction(
|
||||
|
||||
await maybe_assign_promo_group_by_total_spent(db, user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
'Не удалось проверить автовыдачу промогруппы для пользователя %s: %s',
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
logger.debug('Не удалось проверить автовыдачу промогруппы для пользователя', user_id=user_id, exc=exc)
|
||||
if type == TransactionType.SUBSCRIPTION_PAYMENT:
|
||||
try:
|
||||
from app.services.referral_contest_service import referral_contest_service
|
||||
@@ -99,11 +102,7 @@ async def create_transaction(
|
||||
amount_kopeks,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
'Не удалось записать событие конкурса для пользователя %s: %s',
|
||||
user_id,
|
||||
exc,
|
||||
)
|
||||
logger.debug('Не удалось записать событие конкурса для пользователя', user_id=user_id, exc=exc)
|
||||
|
||||
return transaction
|
||||
|
||||
@@ -169,7 +168,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr
|
||||
await db.commit()
|
||||
await db.refresh(transaction)
|
||||
|
||||
logger.info(f'✅ Транзакция {transaction.id} завершена')
|
||||
logger.info('✅ Транзакция завершена', transaction_id=transaction.id)
|
||||
|
||||
try:
|
||||
from app.services.promo_group_assignment import (
|
||||
@@ -179,9 +178,7 @@ async def complete_transaction(db: AsyncSession, transaction: Transaction) -> Tr
|
||||
await maybe_assign_promo_group_by_total_spent(db, transaction.user_id)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
'Не удалось проверить автовыдачу промогруппы для пользователя %s: %s',
|
||||
transaction.user_id,
|
||||
exc,
|
||||
'Не удалось проверить автовыдачу промогруппы для пользователя', user_id=transaction.user_id, exc=exc
|
||||
)
|
||||
|
||||
return transaction
|
||||
@@ -390,7 +387,7 @@ async def check_tribute_payment_duplicate(
|
||||
transaction = result.scalar_one_or_none()
|
||||
|
||||
if transaction:
|
||||
logger.info(f'🔍 Найден дубликат платежа в течение 24ч: {transaction.id}')
|
||||
logger.info('🔍 Найден дубликат платежа в течение 24ч', transaction_id=transaction.id)
|
||||
|
||||
return transaction
|
||||
|
||||
@@ -406,7 +403,7 @@ async def create_unique_tribute_transaction(
|
||||
timestamp = int(datetime.utcnow().timestamp())
|
||||
external_id = f'donation_{payment_id}_{amount_kopeks}_{timestamp}'
|
||||
|
||||
logger.info(f'Создан уникальный external_id для избежания дубликатов: {external_id}')
|
||||
logger.info('Создан уникальный external_id для избежания дубликатов', external_id=external_id)
|
||||
|
||||
return await create_transaction(
|
||||
db=db,
|
||||
|
||||
+176
-56
@@ -1,8 +1,8 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, case, func, nullslast, or_, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -25,7 +25,14 @@ from app.database.models import (
|
||||
from app.utils.validators import sanitize_telegram_name
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _normalize_language_code(language: str | None, fallback: str = 'ru') -> str:
|
||||
normalized = (language or '').strip().lower()
|
||||
if '-' in normalized:
|
||||
normalized = normalized.split('-', 1)[0]
|
||||
return normalized or fallback
|
||||
|
||||
|
||||
def _build_spending_stats_select():
|
||||
@@ -232,6 +239,7 @@ async def create_user_no_commit(
|
||||
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
@@ -243,7 +251,7 @@ async def create_user_no_commit(
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -261,7 +269,11 @@ async def create_user_no_commit(
|
||||
user.promo_group = default_group
|
||||
|
||||
# Не коммитим сразу, оставляем для пакетной обработки
|
||||
logger.info(f'✅ Подготовлен пользователь {telegram_id} с реферальным кодом {referral_code} (ожидает коммита)')
|
||||
logger.info(
|
||||
'✅ Подготовлен пользователь с реферальным кодом (ожидает коммита)',
|
||||
telegram_id=telegram_id,
|
||||
referral_code=referral_code,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@@ -277,6 +289,7 @@ async def create_user(
|
||||
) -> User:
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
|
||||
attempts = 3
|
||||
|
||||
@@ -291,7 +304,7 @@ async def create_user(
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -307,7 +320,9 @@ async def create_user(
|
||||
await db.refresh(user)
|
||||
|
||||
user.promo_group = default_group
|
||||
logger.info(f'✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}')
|
||||
logger.info(
|
||||
'✅ Создан пользователь с реферальным кодом', telegram_id=telegram_id, referral_code=referral_code
|
||||
)
|
||||
|
||||
# Отправляем событие о создании пользователя
|
||||
try:
|
||||
@@ -327,7 +342,7 @@ async def create_user(
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit user.created event: %s', error)
|
||||
logger.warning('Failed to emit user.created event', error=error)
|
||||
|
||||
return user
|
||||
|
||||
@@ -340,11 +355,10 @@ async def create_user(
|
||||
and attempt < attempts
|
||||
):
|
||||
logger.warning(
|
||||
'⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя %s. '
|
||||
'Выполняем повторную синхронизацию (попытка %s/%s)',
|
||||
telegram_id,
|
||||
attempt,
|
||||
attempts,
|
||||
'⚠️ Обнаружено несоответствие последовательности users_id_seq при создании пользователя . Выполняем повторную синхронизацию (попытка /)',
|
||||
telegram_id=telegram_id,
|
||||
attempt=attempt,
|
||||
attempts=attempts,
|
||||
)
|
||||
await _sync_users_sequence(db)
|
||||
continue
|
||||
@@ -360,6 +374,8 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
|
||||
for field, value in kwargs.items():
|
||||
if field in ('first_name', 'last_name'):
|
||||
value = sanitize_telegram_name(value)
|
||||
if field == 'language':
|
||||
value = _normalize_language_code(value)
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
@@ -402,7 +418,11 @@ async def add_user_balance(
|
||||
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info(
|
||||
f'💰 Баланс пользователя {user_id_display} изменен: {old_balance} → {user.balance_kopeks} (изменение: +{amount_kopeks})'
|
||||
'💰 Баланс пользователя изменен: → (изменение: +)',
|
||||
user_id_display=user_id_display,
|
||||
old_balance=old_balance,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
amount_kopeks=amount_kopeks,
|
||||
)
|
||||
|
||||
# Автоматическое возобновление приостановленной суточной подписки
|
||||
@@ -425,8 +445,9 @@ async def add_user_balance(
|
||||
if daily_price > 0 and user.balance_kopeks >= daily_price:
|
||||
await resume_daily_subscription(db, subscription)
|
||||
logger.info(
|
||||
f'✅ Автоматически возобновлена суточная подписка {subscription.id} '
|
||||
f'после пополнения баланса (user_id={user.id})'
|
||||
'✅ Автоматически возобновлена суточная подписка после пополнения баланса (user_id=)',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
@@ -435,14 +456,14 @@ async def add_user_balance(
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as sync_err:
|
||||
logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}')
|
||||
logger.warning('Не удалось синхронизировать с RemnaWave', sync_err=sync_err)
|
||||
except Exception as resume_err:
|
||||
logger.warning(f'Ошибка при попытке возобновить суточную подписку: {resume_err}')
|
||||
logger.warning('Ошибка при попытке возобновить суточную подписку', resume_err=resume_err)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка изменения баланса пользователя {user.id}: {e}')
|
||||
logger.error('Ошибка изменения баланса пользователя', user_id=user.id, error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
@@ -458,7 +479,7 @@ async def add_user_balance_by_id(
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user:
|
||||
logger.error(f'Пользователь с telegram_id {telegram_id} не найден')
|
||||
logger.error('Пользователь с telegram_id не найден', telegram_id=telegram_id)
|
||||
return False
|
||||
|
||||
return await add_user_balance(
|
||||
@@ -471,7 +492,7 @@ async def add_user_balance_by_id(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка пополнения баланса пользователя {telegram_id}: {e}')
|
||||
logger.error('Ошибка пополнения баланса пользователя', telegram_id=telegram_id, error=e)
|
||||
return False
|
||||
|
||||
|
||||
@@ -487,10 +508,14 @@ async def subtract_user_balance(
|
||||
) -> bool:
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info('💸 ОТЛАДКА subtract_user_balance:')
|
||||
logger.info(f' 👤 User ID: {user.id} (ID: {user_id_display})')
|
||||
logger.info(f' 💰 Баланс до списания: {user.balance_kopeks} копеек')
|
||||
logger.info(f' 💸 Сумма к списанию: {amount_kopeks} копеек')
|
||||
logger.info(f' 📝 Описание: {description}')
|
||||
logger.info('👤 User ID: (ID: )', user_id=user.id, user_id_display=user_id_display)
|
||||
logger.info('💰 Баланс до списания: копеек', balance_kopeks=user.balance_kopeks)
|
||||
logger.info('💸 Сумма к списанию: копеек', amount_kopeks=amount_kopeks)
|
||||
logger.info('📝 Описание', description=description)
|
||||
|
||||
# Lock the user row to prevent concurrent balance race conditions
|
||||
locked_result = await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
log_context: dict[str, object] | None = None
|
||||
if consume_promo_offer:
|
||||
@@ -516,9 +541,7 @@ async def subtract_user_balance(
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception as lookup_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to fetch latest claimed promo offer for user %s: %s',
|
||||
user.id,
|
||||
lookup_error,
|
||||
'Failed to fetch latest claimed promo offer for user', user_id=user.id, lookup_error=lookup_error
|
||||
)
|
||||
offer = None
|
||||
|
||||
@@ -543,14 +566,13 @@ async def subtract_user_balance(
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if create_transaction:
|
||||
from app.database.crud.transaction import (
|
||||
create_transaction as create_trans,
|
||||
)
|
||||
|
||||
# create_trans commits the session, atomically persisting
|
||||
# both the balance change and the transaction record
|
||||
await create_trans(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
@@ -559,6 +581,10 @@ async def subtract_user_balance(
|
||||
description=description,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
else:
|
||||
await db.commit()
|
||||
|
||||
await db.refresh(user)
|
||||
|
||||
if consume_promo_offer and log_context:
|
||||
try:
|
||||
@@ -574,23 +600,21 @@ async def subtract_user_balance(
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to record promo offer consumption log for user %s: %s',
|
||||
user.id,
|
||||
log_error,
|
||||
'Failed to record promo offer consumption log for user', user_id=user.id, log_error=log_error
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer consumption log failure: %s',
|
||||
rollback_error,
|
||||
'Failed to rollback session after promo offer consumption log failure',
|
||||
rollback_error=rollback_error,
|
||||
)
|
||||
|
||||
logger.info(f' ✅ Средства списаны: {old_balance} → {user.balance_kopeks}')
|
||||
logger.info('✅ Средства списаны: →', old_balance=old_balance, balance_kopeks=user.balance_kopeks)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f' ❌ ОШИБКА СПИСАНИЯ: {e}')
|
||||
logger.error('❌ ОШИБКА СПИСАНИЯ', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
@@ -625,9 +649,9 @@ async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception as lookup_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to fetch latest claimed promo offer for user %s during expiration cleanup: %s',
|
||||
user.id,
|
||||
lookup_error,
|
||||
'Failed to fetch latest claimed promo offer for user during expiration cleanup',
|
||||
user_id=user.id,
|
||||
lookup_error=lookup_error,
|
||||
)
|
||||
offer = None
|
||||
|
||||
@@ -670,17 +694,12 @@ async def cleanup_expired_promo_offer_discounts(db: AsyncSession) -> int:
|
||||
details={'reason': 'offer_expired'},
|
||||
)
|
||||
except Exception as log_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to log promo offer expiration for user %s: %s',
|
||||
user_id,
|
||||
log_error,
|
||||
)
|
||||
logger.warning('Failed to log promo offer expiration for user', user_id=user_id, log_error=log_error)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception as rollback_error: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
'Failed to rollback session after promo offer expiration log failure: %s',
|
||||
rollback_error,
|
||||
'Failed to rollback session after promo offer expiration log failure', rollback_error=rollback_error
|
||||
)
|
||||
|
||||
return len(users)
|
||||
@@ -926,7 +945,7 @@ async def get_users_for_promo_segment(db: AsyncSession, segment: str) -> list[Us
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.warning('Неизвестный сегмент для промо: %s', segment)
|
||||
logger.warning('Неизвестный сегмент для промо', segment=segment)
|
||||
return []
|
||||
|
||||
result = await db.execute(query.order_by(User.id))
|
||||
@@ -971,7 +990,7 @@ async def delete_user(db: AsyncSession, user: User) -> bool:
|
||||
|
||||
await db.commit()
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info(f'🗑️ Пользователь {user_id_display} помечен как удаленный')
|
||||
logger.info('🗑️ Пользователь помечен как удаленный', user_id_display=user_id_display)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1060,6 +1079,7 @@ async def create_user_by_email(
|
||||
Created User object
|
||||
"""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
user = User(
|
||||
@@ -1071,7 +1091,7 @@ async def create_user_by_email(
|
||||
username=None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=None,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -1085,7 +1105,7 @@ async def create_user_by_email(
|
||||
await db.refresh(user)
|
||||
|
||||
user.promo_group = default_group
|
||||
logger.info(f'✅ Создан email-пользователь {email} с id={user.id}')
|
||||
logger.info('✅ Создан email-пользователь с id', email=email, user_id=user.id)
|
||||
|
||||
# Emit event
|
||||
try:
|
||||
@@ -1104,7 +1124,7 @@ async def create_user_by_email(
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit user.created event: %s', error)
|
||||
logger.warning('Failed to emit user.created event', error=error)
|
||||
|
||||
return user
|
||||
|
||||
@@ -1162,7 +1182,7 @@ async def set_email_change_pending(
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f'Email change pending for user {user.id}: {user.email} -> {new_email}')
|
||||
logger.info('Email change pending for user', user_id=user.id, email=user.email, new_email=new_email)
|
||||
return user
|
||||
|
||||
|
||||
@@ -1216,7 +1236,7 @@ async def verify_and_apply_email_change(db: AsyncSession, user: User, code: str)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(f'Email changed for user {user.id}: {old_email} -> {new_email}')
|
||||
logger.info('Email changed for user', user_id=user.id, old_email=old_email, new_email=new_email)
|
||||
return True, 'Email changed successfully'
|
||||
|
||||
|
||||
@@ -1234,4 +1254,104 @@ async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'Email change cancelled for user {user.id}')
|
||||
logger.info('Email change cancelled for user', user_id=user.id)
|
||||
|
||||
|
||||
# --- OAuth provider functions ---
|
||||
|
||||
_OAUTH_PROVIDER_COLUMNS = {
|
||||
'google': 'google_id',
|
||||
'yandex': 'yandex_id',
|
||||
'discord': 'discord_id',
|
||||
'vk': 'vk_id',
|
||||
}
|
||||
|
||||
|
||||
async def get_user_by_oauth_provider(db: AsyncSession, provider: str, provider_id: str) -> User | None:
|
||||
"""Find a user by OAuth provider ID."""
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column_name:
|
||||
return None
|
||||
column = getattr(User, column_name)
|
||||
# VK uses BigInteger, so convert
|
||||
value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
result = await db.execute(select(User).where(column == value))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str, provider_id: str) -> None:
|
||||
"""Link an OAuth provider ID to an existing user."""
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
if not column_name:
|
||||
return
|
||||
value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
setattr(user, column_name, value)
|
||||
user.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
logger.info('Linked (id=) to user', provider=provider, provider_id=provider_id, user_id=user.id)
|
||||
|
||||
|
||||
async def create_user_by_oauth(
|
||||
db: AsyncSession,
|
||||
provider: str,
|
||||
provider_id: str,
|
||||
email: str | None = None,
|
||||
email_verified: bool = False,
|
||||
first_name: str | None = None,
|
||||
last_name: str | None = None,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
) -> User:
|
||||
"""Create a new user via OAuth provider."""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
provider_value: str | int = int(provider_id) if provider == 'vk' else provider_id
|
||||
|
||||
user = User(
|
||||
telegram_id=None,
|
||||
auth_type=provider,
|
||||
email=email,
|
||||
email_verified=email_verified,
|
||||
password_hash=None,
|
||||
username=sanitize_telegram_name(username) if username else None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=sanitize_telegram_name(last_name) if last_name else None,
|
||||
language=normalized_language,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=default_group.id,
|
||||
)
|
||||
if column_name:
|
||||
setattr(user, column_name, provider_value)
|
||||
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
await db.refresh(user)
|
||||
|
||||
user.promo_group = default_group
|
||||
logger.info(
|
||||
'Created OAuth user via (provider_id=) with id', provider=provider, provider_id=provider_id, user_id=user.id
|
||||
)
|
||||
|
||||
try:
|
||||
from app.services.event_emitter import event_emitter
|
||||
|
||||
await event_emitter.emit(
|
||||
'user.created',
|
||||
{
|
||||
'user_id': user.id,
|
||||
'email': user.email,
|
||||
'auth_type': provider,
|
||||
'first_name': user.first_name,
|
||||
'referral_code': user.referral_code,
|
||||
},
|
||||
db=db,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning('Failed to emit user.created event', error=error)
|
||||
|
||||
return user
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.database.models import User, UserMessage
|
||||
from app.utils.validators import sanitize_html, validate_html_tags
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_user_message(
|
||||
@@ -36,7 +36,7 @@ async def create_user_message(
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
logger.info(f'✅ Создано сообщение ID {message.id} пользователем {created_by}')
|
||||
logger.info('✅ Создано сообщение ID пользователем', message_id=message.id, created_by=created_by)
|
||||
return message
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ async def update_user_message(
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
logger.info(f'📝 Обновлено сообщение ID {message_id}')
|
||||
logger.info('📝 Обновлено сообщение ID', message_id=message_id)
|
||||
return message
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ async def toggle_user_message_status(db: AsyncSession, message_id: int) -> UserM
|
||||
await db.refresh(message)
|
||||
|
||||
status_text = 'активировано' if message.is_active else 'деактивировано'
|
||||
logger.info(f'🔄 Сообщение ID {message_id} {status_text}')
|
||||
logger.info('🔄 Сообщение ID', message_id=message_id, status_text=status_text)
|
||||
|
||||
return message
|
||||
|
||||
@@ -147,7 +147,7 @@ async def delete_user_message(db: AsyncSession, message_id: int) -> bool:
|
||||
await db.delete(message)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'🗑️ Удалено сообщение ID {message_id}')
|
||||
logger.info('🗑️ Удалено сообщение ID', message_id=message_id)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""CRUD операции для связи пользователей с промогруппами (Many-to-Many)."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import selectinload
|
||||
from app.database.models import PromoGroup, User, UserPromoGroup
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def _sync_user_primary_promo_group(
|
||||
@@ -39,11 +39,7 @@ async def _sync_user_primary_promo_group(
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка синхронизации primary промогруппы пользователя %s: %s',
|
||||
user_id,
|
||||
error,
|
||||
)
|
||||
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
|
||||
|
||||
async def sync_user_primary_promo_group(
|
||||
@@ -74,7 +70,7 @@ async def add_user_to_promo_group(
|
||||
# Проверяем существование связи
|
||||
existing = await has_user_promo_group(db, user_id, promo_group_id)
|
||||
if existing:
|
||||
logger.info(f'Пользователь {user_id} уже имеет промогруппу {promo_group_id}')
|
||||
logger.info('Пользователь уже имеет промогруппу', user_id=user_id, promo_group_id=promo_group_id)
|
||||
return None
|
||||
|
||||
# Создаем новую связь
|
||||
@@ -91,11 +87,16 @@ async def add_user_to_promo_group(
|
||||
await db.commit()
|
||||
await db.refresh(user_promo_group)
|
||||
|
||||
logger.info(f'Пользователю {user_id} добавлена промогруппа {promo_group_id} ({assigned_by})')
|
||||
logger.info(
|
||||
'Пользователю добавлена промогруппа',
|
||||
user_id=user_id,
|
||||
promo_group_id=promo_group_id,
|
||||
assigned_by=assigned_by,
|
||||
)
|
||||
return user_promo_group
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'Ошибка добавления промогруппы пользователю: {error}')
|
||||
logger.error('Ошибка добавления промогруппы пользователю', error=error)
|
||||
await db.rollback()
|
||||
return None
|
||||
|
||||
@@ -121,7 +122,9 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
|
||||
user_promo_group = result.scalar_one_or_none()
|
||||
|
||||
if not user_promo_group:
|
||||
logger.warning(f'Связь пользователя {user_id} с промогруппой {promo_group_id} не найдена')
|
||||
logger.warning(
|
||||
'Связь пользователя с промогруппой не найдена', user_id=user_id, promo_group_id=promo_group_id
|
||||
)
|
||||
return False
|
||||
|
||||
await db.delete(user_promo_group)
|
||||
@@ -131,11 +134,11 @@ async def remove_user_from_promo_group(db: AsyncSession, user_id: int, promo_gro
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'У пользователя {user_id} удалена промогруппа {promo_group_id}')
|
||||
logger.info('У пользователя удалена промогруппа', user_id=user_id, promo_group_id=promo_group_id)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'Ошибка удаления промогруппы у пользователя: {error}')
|
||||
logger.error('Ошибка удаления промогруппы у пользователя', error=error)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
@@ -162,7 +165,7 @@ async def get_user_promo_groups(db: AsyncSession, user_id: int) -> list[UserProm
|
||||
return list(result.scalars().all())
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'Ошибка получения промогрупп пользователя {user_id}: {error}')
|
||||
logger.error('Ошибка получения промогрупп пользователя', user_id=user_id, error=error)
|
||||
return []
|
||||
|
||||
|
||||
@@ -187,7 +190,7 @@ async def get_primary_user_promo_group(db: AsyncSession, user_id: int) -> PromoG
|
||||
return user_promo_groups[0].promo_group if user_promo_groups[0].promo_group else None
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'Ошибка получения primary промогруппы пользователя {user_id}: {error}')
|
||||
logger.error('Ошибка получения primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
return None
|
||||
|
||||
|
||||
@@ -212,7 +215,7 @@ async def has_user_promo_group(db: AsyncSession, user_id: int, promo_group_id: i
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'Ошибка проверки промогруппы пользователя: {error}')
|
||||
logger.error('Ошибка проверки промогруппы пользователя', error=error)
|
||||
return False
|
||||
|
||||
|
||||
@@ -232,7 +235,7 @@ async def count_user_promo_groups(db: AsyncSession, user_id: int) -> int:
|
||||
return len(list(result.scalars().all()))
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'Ошибка подсчета промогрупп пользователя: {error}')
|
||||
logger.error('Ошибка подсчета промогрупп пользователя', error=error)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -264,10 +267,10 @@ async def replace_user_promo_groups(
|
||||
db.add(user_promo_group)
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'Промогруппы пользователя {user_id} заменены на {promo_group_ids}')
|
||||
logger.info('Промогруппы пользователя заменены на', user_id=user_id, promo_group_ids=promo_group_ids)
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'Ошибка замены промогрупп пользователя: {error}')
|
||||
logger.error('Ошибка замены промогрупп пользователя', error=error)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
+12
-14
@@ -1,16 +1,16 @@
|
||||
"""CRUD helpers for WATA payment records."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import WataPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_wata_payment(
|
||||
@@ -53,11 +53,11 @@ async def create_wata_payment(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Создан Wata платеж #%s для пользователя %s: %s копеек (статус %s)',
|
||||
payment.id,
|
||||
user_id,
|
||||
amount_kopeks,
|
||||
status,
|
||||
'Создан Wata платеж # для пользователя : копеек (статус)',
|
||||
payment_id=payment.id,
|
||||
user_id=user_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
status=status,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -128,10 +128,10 @@ async def update_wata_payment_status(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Обновлен Wata платеж %s: статус=%s, is_paid=%s',
|
||||
payment.payment_link_id,
|
||||
payment.status,
|
||||
payment.is_paid,
|
||||
'Обновлен Wata платеж : статус is_paid',
|
||||
payment_link_id=payment.payment_link_id,
|
||||
payment_status=payment.status,
|
||||
is_paid=payment.is_paid,
|
||||
)
|
||||
|
||||
return payment
|
||||
@@ -147,9 +147,7 @@ async def link_wata_payment_to_transaction(
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(
|
||||
'Wata платеж %s привязан к транзакции %s',
|
||||
payment.payment_link_id,
|
||||
transaction_id,
|
||||
'Wata платеж привязан к транзакции', payment_link_id=payment.payment_link_id, transaction_id=transaction_id
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User, WelcomeText
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
WELCOME_TEXT_KEY = 'welcome_text'
|
||||
|
||||
@@ -83,7 +83,7 @@ async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
|
||||
await db.refresh(welcome_text)
|
||||
|
||||
status = 'включен' if welcome_text.is_enabled else 'отключен'
|
||||
logger.info(f'Приветственный текст {status} администратором {admin_id}')
|
||||
logger.info('Приветственный текст администратором', status=status, admin_id=admin_id)
|
||||
return welcome_text.is_enabled
|
||||
default_text = await get_current_welcome_text_or_default()
|
||||
new_welcome_text = WelcomeText(text_content=default_text, is_active=True, is_enabled=True, created_by=admin_id)
|
||||
@@ -92,11 +92,11 @@ async def toggle_welcome_text_status(db: AsyncSession, admin_id: int) -> bool:
|
||||
await db.commit()
|
||||
await db.refresh(new_welcome_text)
|
||||
|
||||
logger.info(f'Создан и включен дефолтный приветственный текст администратором {admin_id}')
|
||||
logger.info('Создан и включен дефолтный приветственный текст администратором', admin_id=admin_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при переключении статуса приветственного текста: {e}')
|
||||
logger.error('Ошибка при переключении статуса приветственного текста', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
@@ -116,11 +116,11 @@ async def set_welcome_text(db: AsyncSession, text_content: str, admin_id: int) -
|
||||
await db.commit()
|
||||
await db.refresh(new_welcome_text)
|
||||
|
||||
logger.info(f'Установлен новый приветственный текст администратором {admin_id}')
|
||||
logger.info('Установлен новый приветственный текст администратором', admin_id=admin_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка при установке приветственного текста: {e}')
|
||||
logger.error('Ошибка при установке приветственного текста', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
@@ -154,10 +154,10 @@ async def create_welcome_text(
|
||||
await db.refresh(welcome_text)
|
||||
|
||||
logger.info(
|
||||
'✅ Создан приветственный текст ID %s (активный=%s, включен=%s)',
|
||||
welcome_text.id,
|
||||
welcome_text.is_active,
|
||||
welcome_text.is_enabled,
|
||||
'✅ Создан приветственный текст ID (активный включен=)',
|
||||
welcome_text_id=welcome_text.id,
|
||||
is_active=welcome_text.is_active,
|
||||
is_enabled=welcome_text.is_enabled,
|
||||
)
|
||||
return welcome_text
|
||||
|
||||
@@ -188,10 +188,10 @@ async def update_welcome_text(
|
||||
await db.refresh(welcome_text)
|
||||
|
||||
logger.info(
|
||||
'📝 Обновлен приветственный текст ID %s (активный=%s, включен=%s)',
|
||||
welcome_text.id,
|
||||
welcome_text.is_active,
|
||||
welcome_text.is_enabled,
|
||||
'📝 Обновлен приветственный текст ID (активный включен=)',
|
||||
welcome_text_id=welcome_text.id,
|
||||
is_active=welcome_text.is_active,
|
||||
is_enabled=welcome_text.is_enabled,
|
||||
)
|
||||
return welcome_text
|
||||
|
||||
@@ -199,7 +199,7 @@ async def update_welcome_text(
|
||||
async def delete_welcome_text(db: AsyncSession, welcome_text: WelcomeText) -> None:
|
||||
await db.delete(welcome_text)
|
||||
await db.commit()
|
||||
logger.info('🗑️ Удален приветственный текст ID %s', welcome_text.id)
|
||||
logger.info('🗑️ Удален приветственный текст ID', welcome_text_id=welcome_text.id)
|
||||
|
||||
|
||||
async def get_current_welcome_text_or_default() -> str:
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
CRUD операции для колеса удачи (Fortune Wheel).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -17,7 +17,7 @@ from app.database.models import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
# ==================== WHEEL CONFIG ====================
|
||||
@@ -68,7 +68,7 @@ async def update_wheel_config(db: AsyncSession, **kwargs) -> WheelConfig:
|
||||
config.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(config)
|
||||
logger.info(f'🎡 Обновлена конфигурация колеса: {kwargs}')
|
||||
logger.info('🎡 Обновлена конфигурация колеса', kwargs=kwargs)
|
||||
return config
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ async def create_wheel_prize(
|
||||
db.add(prize)
|
||||
await db.commit()
|
||||
await db.refresh(prize)
|
||||
logger.info(f'🎁 Создан приз колеса: {display_name} ({prize_type})')
|
||||
logger.info('🎁 Создан приз колеса', display_name=display_name, prize_type=prize_type)
|
||||
return prize
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ async def update_wheel_prize(db: AsyncSession, prize_id: int, **kwargs) -> Wheel
|
||||
prize.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(prize)
|
||||
logger.info(f'🎁 Обновлен приз колеса ID={prize_id}: {kwargs}')
|
||||
logger.info('🎁 Обновлен приз колеса ID', prize_id=prize_id, kwargs=kwargs)
|
||||
return prize
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ async def delete_wheel_prize(db: AsyncSession, prize_id: int) -> bool:
|
||||
|
||||
await db.delete(prize)
|
||||
await db.commit()
|
||||
logger.info(f'🗑️ Удален приз колеса ID={prize_id}')
|
||||
logger.info('🗑️ Удален приз колеса ID', prize_id=prize_id)
|
||||
return True
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ async def reorder_wheel_prizes(db: AsyncSession, prize_ids: list[int]) -> bool:
|
||||
prize.sort_order = index
|
||||
|
||||
await db.commit()
|
||||
logger.info(f'🔄 Переупорядочены призы колеса: {prize_ids}')
|
||||
logger.info('🔄 Переупорядочены призы колеса', prize_ids=prize_ids)
|
||||
return True
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ async def create_wheel_spin(
|
||||
db.add(spin)
|
||||
await db.commit()
|
||||
await db.refresh(spin)
|
||||
logger.info(f"🎰 Создан спин колеса: user_id={user_id}, prize='{prize_display_name}'")
|
||||
logger.info('🎰 Создан спин колеса: user_id=, prize', user_id=user_id, prize_display_name=prize_display_name)
|
||||
return spin
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import YooKassaPayment
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def create_yookassa_payment(
|
||||
@@ -24,7 +25,7 @@ async def create_yookassa_payment(
|
||||
payment_method_type: str | None = None,
|
||||
yookassa_created_at: datetime | None = None,
|
||||
test_mode: bool = False,
|
||||
) -> YooKassaPayment:
|
||||
) -> YooKassaPayment | None:
|
||||
payment = YooKassaPayment(
|
||||
user_id=user_id,
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
@@ -40,10 +41,25 @@ async def create_yookassa_payment(
|
||||
)
|
||||
|
||||
db.add(payment)
|
||||
await db.commit()
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
'FK violation при создании платежа YooKassa : user_id= не существует в БД',
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
user_id=user_id,
|
||||
e=e,
|
||||
)
|
||||
return None
|
||||
await db.refresh(payment)
|
||||
|
||||
logger.info(f'Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks / 100}₽ для пользователя {user_id}')
|
||||
logger.info(
|
||||
'Создан платеж YooKassa: на ₽ для пользователя',
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
amount_kopeks=amount_kopeks / 100,
|
||||
user_id=user_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -93,7 +109,12 @@ async def update_yookassa_payment_status(
|
||||
payment = result.scalar_one_or_none()
|
||||
|
||||
if payment:
|
||||
logger.info(f'Обновлен статус платежа YooKassa {yookassa_payment_id}: {status}, paid={is_paid}')
|
||||
logger.info(
|
||||
'Обновлен статус платежа YooKassa , paid',
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
status=status,
|
||||
is_paid=is_paid,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -116,7 +137,11 @@ async def link_yookassa_payment_to_transaction(
|
||||
payment = result.scalar_one_or_none()
|
||||
|
||||
if payment:
|
||||
logger.info(f'Платеж YooKassa {yookassa_payment_id} связан с транзакцией {transaction_id}')
|
||||
logger.info(
|
||||
'Платеж YooKassa связан с транзакцией',
|
||||
yookassa_payment_id=yookassa_payment_id,
|
||||
transaction_id=transaction_id,
|
||||
)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -174,7 +199,7 @@ async def delete_yookassa_payment(db: AsyncSession, yookassa_payment_id: str) ->
|
||||
if payment:
|
||||
await db.delete(payment)
|
||||
await db.commit()
|
||||
logger.info(f'Удален платеж YooKassa: {yookassa_payment_id}')
|
||||
logger.info('Удален платеж YooKassa', yookassa_payment_id=yookassa_payment_id)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
+22
-24
@@ -1,11 +1,11 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import wraps
|
||||
from typing import TypeVar
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import bindparam, event, inspect, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.exc import InterfaceError, OperationalError, ProgrammingError
|
||||
@@ -16,7 +16,7 @@ from app.config import settings
|
||||
from app.database.models import Base
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
@@ -126,16 +126,16 @@ def with_db_retry(
|
||||
last_exception = e
|
||||
if attempt < attempts:
|
||||
logger.warning(
|
||||
'Ошибка БД (попытка %d/%d): %s. Повтор через %.1f сек...',
|
||||
attempt,
|
||||
attempts,
|
||||
str(e)[:100],
|
||||
current_delay,
|
||||
'Ошибка БД (попытка /): . Повтор через сек...',
|
||||
attempt=attempt,
|
||||
attempts=attempts,
|
||||
e=str(e)[:100],
|
||||
current_delay=current_delay,
|
||||
)
|
||||
await asyncio.sleep(current_delay)
|
||||
current_delay *= backoff
|
||||
else:
|
||||
logger.error('Ошибка БД: все %d попыток исчерпаны. Последняя ошибка: %s', attempts, str(e))
|
||||
logger.error('Ошибка БД: все попыток исчерпаны. Последняя ошибка', attempts=attempts, e=str(e))
|
||||
|
||||
raise last_exception
|
||||
|
||||
@@ -159,7 +159,7 @@ async def execute_with_retry(
|
||||
except RETRYABLE_EXCEPTIONS as e:
|
||||
last_exception = e
|
||||
if attempt < attempts:
|
||||
logger.warning('SQL retry (попытка %d/%d): %s', attempt, attempts, str(e)[:100])
|
||||
logger.warning('SQL retry (попытка /)', attempt=attempt, attempts=attempts, e=str(e)[:100])
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
|
||||
@@ -175,15 +175,15 @@ if settings.DEBUG:
|
||||
@event.listens_for(Engine, 'before_cursor_execute')
|
||||
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
conn.info.setdefault('query_start_time', []).append(time.time())
|
||||
logger.debug(f'🔍 Executing query: {statement[:100]}...')
|
||||
logger.debug('🔍 Executing query: ...', statement=statement[:100])
|
||||
|
||||
@event.listens_for(Engine, 'after_cursor_execute')
|
||||
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
total = time.time() - conn.info['query_start_time'].pop(-1)
|
||||
if total > 0.1: # Логируем медленные запросы > 100ms
|
||||
logger.warning(f'🐌 Slow query ({total:.3f}s): {statement[:100]}...')
|
||||
logger.warning('🐌 Slow query (s): ...', total=round(total, 3), statement=statement[:100])
|
||||
else:
|
||||
logger.debug(f'⚡ Query executed in {total:.3f}s')
|
||||
logger.debug('⚡ Query executed in', total=round(total, 3))
|
||||
|
||||
# ============================================================================
|
||||
# ADVANCED SESSION MANAGER WITH READ REPLICAS
|
||||
@@ -201,7 +201,7 @@ def _validate_database_url(url: str | None) -> str | None:
|
||||
return None
|
||||
# Простая проверка на валидный формат
|
||||
if not ('://' in url or url.startswith('sqlite')):
|
||||
logger.warning('Невалидный DATABASE_URL: %s', url[:20])
|
||||
logger.warning('Невалидный DATABASE_URL', url=url[:20])
|
||||
return None
|
||||
return url
|
||||
|
||||
@@ -234,9 +234,9 @@ class DatabaseManager:
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
logger.info('Read replica настроена: %s', replica_url[:30] + '...')
|
||||
logger.info('Read replica настроена', replica_url=replica_url[:30] + '...')
|
||||
except Exception as e:
|
||||
logger.error('Не удалось настроить read replica: %s', e)
|
||||
logger.error('Не удалось настроить read replica', e=e)
|
||||
self.read_replica_engine = None
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -276,10 +276,10 @@ class DatabaseManager:
|
||||
latency = (time.time() - start) * 1000
|
||||
status = 'healthy'
|
||||
except TimeoutError:
|
||||
logger.error('Health check таймаут (%s сек)', timeout)
|
||||
logger.error('Health check таймаут (сек)', timeout=timeout)
|
||||
status = 'timeout'
|
||||
except Exception as e:
|
||||
logger.error('Database health check failed: %s', e)
|
||||
logger.error('Database health check failed', e=e)
|
||||
status = 'unhealthy'
|
||||
|
||||
return {
|
||||
@@ -307,7 +307,7 @@ class DatabaseManager:
|
||||
except TimeoutError:
|
||||
status = 'timeout'
|
||||
except Exception as e:
|
||||
logger.error('Read replica health check failed: %s', e)
|
||||
logger.error('Read replica health check failed', e=e)
|
||||
|
||||
return {
|
||||
'status': status,
|
||||
@@ -440,7 +440,7 @@ async def init_db():
|
||||
# Продолжаем выполнение, так как основные таблицы могут быть созданы
|
||||
else:
|
||||
# Для других ошибок пробрасываем исключение
|
||||
logger.error(f'❌ Ошибка при создании таблиц: {e}')
|
||||
logger.error('❌ Ошибка при создании таблиц', error=e)
|
||||
raise
|
||||
|
||||
if not IS_SQLITE:
|
||||
@@ -468,21 +468,19 @@ async def init_db():
|
||||
|
||||
if not table_exists:
|
||||
logger.debug(
|
||||
'Пропускаем создание индекса %s: таблица %s отсутствует',
|
||||
index_sql,
|
||||
table_name,
|
||||
'Пропускаем создание индекса : таблица отсутствует', index_sql=index_sql, table_name=table_name
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await conn.execute(text(index_sql))
|
||||
except Exception as e:
|
||||
logger.debug('Index creation skipped for %s: %s', table_name, e)
|
||||
logger.debug('Index creation skipped for', table_name=table_name, e=e)
|
||||
|
||||
logger.info('База данных успешно инициализирована')
|
||||
|
||||
health = await db_manager.health_check()
|
||||
logger.info('Database health: %s', health)
|
||||
logger.info('Database health', health=health)
|
||||
|
||||
|
||||
async def close_db():
|
||||
|
||||
+40
-15
@@ -19,7 +19,7 @@ from sqlalchemy import (
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, backref, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
@@ -995,6 +995,11 @@ class User(Base):
|
||||
email_change_new = Column(String(255), nullable=True) # New email pending verification
|
||||
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
|
||||
email_change_expires = Column(DateTime, nullable=True) # Code expiration
|
||||
# OAuth provider IDs
|
||||
google_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
yandex_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
discord_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
|
||||
broadcasts = relationship('BroadcastHistory', back_populates='admin')
|
||||
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
|
||||
subscription = relationship('Subscription', back_populates='user', uselist=False)
|
||||
@@ -1055,6 +1060,11 @@ class User(Base):
|
||||
"""Пользователь зарегистрирован через email (без Telegram)."""
|
||||
return self.auth_type == 'email' and self.telegram_id is None
|
||||
|
||||
@property
|
||||
def is_web_user(self) -> bool:
|
||||
"""Пользователь без Telegram (email, OAuth и т.д.)."""
|
||||
return self.telegram_id is None
|
||||
|
||||
def get_primary_promo_group(self):
|
||||
"""Возвращает промогруппу с максимальным приоритетом."""
|
||||
if not self.user_promo_groups:
|
||||
@@ -1127,6 +1137,8 @@ class Subscription(Base):
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
last_webhook_update_at = Column(DateTime, nullable=True)
|
||||
|
||||
remnawave_short_uuid = Column(String(255), nullable=True)
|
||||
|
||||
# Тариф (для режима продаж "Тарифы")
|
||||
@@ -1141,23 +1153,35 @@ class Subscription(Base):
|
||||
user = relationship('User', back_populates='subscription')
|
||||
tariff = relationship('Tariff', back_populates='subscriptions')
|
||||
discount_offers = relationship('DiscountOffer', back_populates='subscription')
|
||||
temporary_accesses = relationship('SubscriptionTemporaryAccess', back_populates='subscription')
|
||||
traffic_purchases = relationship('TrafficPurchase', back_populates='subscription', cascade='all, delete-orphan')
|
||||
temporary_accesses = relationship(
|
||||
'SubscriptionTemporaryAccess', back_populates='subscription', passive_deletes=True
|
||||
)
|
||||
traffic_purchases = relationship(
|
||||
'TrafficPurchase', back_populates='subscription', passive_deletes=True, cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date > current_time
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value
|
||||
and self.end_date is not None
|
||||
and self.end_date > current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Проверяет, истёк ли срок подписки"""
|
||||
return self.end_date <= datetime.utcnow()
|
||||
return self.end_date is not None and self.end_date <= datetime.utcnow()
|
||||
|
||||
@property
|
||||
def should_be_expired(self) -> bool:
|
||||
current_time = datetime.utcnow()
|
||||
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date <= current_time
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE.value
|
||||
and self.end_date is not None
|
||||
and self.end_date <= current_time
|
||||
)
|
||||
|
||||
@property
|
||||
def actual_status(self) -> str:
|
||||
@@ -1170,12 +1194,12 @@ class Subscription(Base):
|
||||
return 'disabled'
|
||||
|
||||
if self.status == SubscriptionStatus.ACTIVE.value:
|
||||
if self.end_date <= current_time:
|
||||
if self.end_date is None or self.end_date <= current_time:
|
||||
return 'expired'
|
||||
return 'active'
|
||||
|
||||
if self.status == SubscriptionStatus.TRIAL.value:
|
||||
if self.end_date <= current_time:
|
||||
if self.end_date is None or self.end_date <= current_time:
|
||||
return 'expired'
|
||||
return 'trial'
|
||||
|
||||
@@ -1218,6 +1242,8 @@ class Subscription(Base):
|
||||
|
||||
@property
|
||||
def days_left(self) -> int:
|
||||
if self.end_date is None:
|
||||
return 0
|
||||
current_time = datetime.utcnow()
|
||||
if self.end_date <= current_time:
|
||||
return 0
|
||||
@@ -1243,11 +1269,10 @@ class Subscription(Base):
|
||||
|
||||
@property
|
||||
def traffic_used_percent(self) -> float:
|
||||
if self.traffic_limit_gb == 0:
|
||||
if not self.traffic_limit_gb:
|
||||
return 0.0
|
||||
if self.traffic_limit_gb > 0:
|
||||
return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0)
|
||||
return 0.0
|
||||
used = self.traffic_used_gb or 0.0
|
||||
return min((used / self.traffic_limit_gb) * 100, 100.0)
|
||||
|
||||
def extend_subscription(self, days: int):
|
||||
if self.end_date > datetime.utcnow():
|
||||
@@ -1758,7 +1783,7 @@ class SentNotification(Base):
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship('User', backref='sent_notifications')
|
||||
subscription = relationship('Subscription', backref='sent_notifications')
|
||||
subscription = relationship('Subscription', backref=backref('sent_notifications', passive_deletes=True))
|
||||
|
||||
|
||||
class SubscriptionEvent(Base):
|
||||
@@ -2048,7 +2073,7 @@ class SubscriptionServer(Base):
|
||||
|
||||
paid_price_kopeks = Column(Integer, default=0)
|
||||
|
||||
subscription = relationship('Subscription', backref='subscription_servers')
|
||||
subscription = relationship('Subscription', backref=backref('subscription_servers', passive_deletes=True))
|
||||
server_squad = relationship('ServerSquad', backref='subscription_servers')
|
||||
|
||||
|
||||
@@ -2388,7 +2413,7 @@ class ButtonClickLog(Base):
|
||||
clicked_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# Дополнительная информация
|
||||
button_type = Column(String(20), nullable=True) # builtin, callback, url, mini_app
|
||||
button_type = Column(String(20), nullable=True, index=True) # builtin, callback, url, mini_app
|
||||
button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
+310
-203
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user