Compare commits
168 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 | |||
| 916ad9d567 |
+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
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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.7.1"
|
||||
".": "3.12.1"
|
||||
}
|
||||
|
||||
+210
@@ -1,5 +1,215 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.7.1" # x-release-please-version
|
||||
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ч
|
||||
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
|
||||
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
|
||||
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
|
||||
|
||||
|
||||
+51
-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
|
||||
@@ -63,7 +62,7 @@ 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
|
||||
@@ -76,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]:
|
||||
@@ -91,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
|
||||
@@ -107,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())
|
||||
@@ -124,10 +126,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.message.middleware(blacklist_middleware)
|
||||
dp.callback_query.middleware(blacklist_middleware)
|
||||
dp.pre_checkout_query.middleware(blacklist_middleware)
|
||||
display_name_middleware = DisplayNameRestrictionMiddleware()
|
||||
dp.message.middleware(display_name_middleware)
|
||||
dp.callback_query.middleware(display_name_middleware)
|
||||
dp.pre_checkout_query.middleware(display_name_middleware)
|
||||
dp.message.middleware(ThrottlingMiddleware())
|
||||
dp.callback_query.middleware(ThrottlingMiddleware())
|
||||
|
||||
@@ -210,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
|
||||
@@ -225,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)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"""OAuth 2.0 provider implementations for cabinet authentication."""
|
||||
|
||||
import logging
|
||||
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 = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
STATE_TTL_SECONDS = 600 # 10 minutes
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -18,7 +18,7 @@ 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)
|
||||
|
||||
@@ -161,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
|
||||
@@ -18,6 +20,7 @@ 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
|
||||
@@ -86,7 +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
|
||||
@@ -38,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'])
|
||||
|
||||
@@ -169,7 +168,7 @@ async def update_tariff_order(
|
||||
await reorder_tariffs(db, request.tariff_ids)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.id} updated tariff order: {request.tariff_ids}')
|
||||
logger.info('Admin updated tariff order', admin_id=admin.id, tariff_ids=request.tariff_ids)
|
||||
|
||||
return {'message': 'Tariff order updated successfully'}
|
||||
|
||||
@@ -295,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)
|
||||
@@ -390,7 +389,7 @@ async def update_existing_tariff(
|
||||
if request.promo_group_ids is not None:
|
||||
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
|
||||
logger.info('Admin updated tariff', admin_id=admin.id, tariff_id=tariff_id)
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
@@ -412,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)
|
||||
@@ -447,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)
|
||||
@@ -490,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,
|
||||
@@ -473,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:
|
||||
@@ -488,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)
|
||||
|
||||
|
||||
@@ -3,34 +3,36 @@
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
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 select
|
||||
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, User
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
TrafficEnrichmentResponse,
|
||||
TrafficNodeInfo,
|
||||
TrafficUsageResponse,
|
||||
UserTrafficEnrichment,
|
||||
UserTrafficItem,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
|
||||
|
||||
@@ -44,6 +46,7 @@ _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:
|
||||
@@ -107,7 +110,7 @@ async def _aggregate_traffic(
|
||||
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
|
||||
return node.uuid, stats
|
||||
except Exception:
|
||||
logger.warning('Failed to get traffic for node %s', node.name, exc_info=True)
|
||||
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))
|
||||
@@ -186,9 +189,14 @@ def _build_traffic_items(
|
||||
|
||||
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():
|
||||
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
|
||||
@@ -223,6 +231,7 @@ def _build_traffic_items(
|
||||
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,
|
||||
@@ -322,15 +331,31 @@ async def get_traffic_usage(
|
||||
if not node_filter:
|
||||
node_filter = None # No valid nodes matched, treat as "all nodes"
|
||||
|
||||
# Validate sort_by: allow known fields + 'node_<uuid>' for dynamic node columns
|
||||
# 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
|
||||
if sort_by not in _SORT_FIELDS and not is_node_sort:
|
||||
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, sort_by, sort_desc, tariff_filter, status_filter, node_filter
|
||||
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]
|
||||
|
||||
@@ -346,6 +371,156 @@ async def get_traffic_usage(
|
||||
)
|
||||
|
||||
|
||||
# ============== 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,
|
||||
@@ -386,6 +561,7 @@ async def export_traffic_csv(
|
||||
|
||||
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
|
||||
@@ -434,12 +610,21 @@ async def export_traffic_csv(
|
||||
'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,
|
||||
'Devices': item.device_limit,
|
||||
'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
|
||||
@@ -500,7 +685,7 @@ async def export_traffic_csv(
|
||||
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
|
||||
)
|
||||
except Exception:
|
||||
logger.error('Failed to send CSV to admin %s', admin.telegram_id, exc_info=True)
|
||||
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.',
|
||||
|
||||
@@ -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)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Admin routes for managing users in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Integer, and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -27,7 +27,9 @@ from app.database.crud.user import (
|
||||
from app.database.models import (
|
||||
PromoGroup,
|
||||
Subscription,
|
||||
SubscriptionServer,
|
||||
SubscriptionStatus,
|
||||
TrafficPurchase,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
@@ -37,8 +39,10 @@ from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.users import (
|
||||
DeleteDeviceResponse,
|
||||
DeleteUserRequest,
|
||||
DeleteUserResponse,
|
||||
DeviceInfo,
|
||||
DisableUserRequest,
|
||||
DisableUserResponse,
|
||||
FullDeleteUserRequest,
|
||||
@@ -46,6 +50,7 @@ from ..schemas.users import (
|
||||
PanelSyncStatusResponse,
|
||||
PanelUserInfo,
|
||||
PeriodPriceInfo,
|
||||
ResetDevicesResponse,
|
||||
ResetSubscriptionRequest,
|
||||
ResetSubscriptionResponse,
|
||||
ResetTrialRequest,
|
||||
@@ -55,10 +60,13 @@ from ..schemas.users import (
|
||||
SyncFromPanelResponse,
|
||||
SyncToPanelRequest,
|
||||
SyncToPanelResponse,
|
||||
TrafficPurchaseItem,
|
||||
UpdateBalanceRequest,
|
||||
UpdateBalanceResponse,
|
||||
UpdatePromoGroupRequest,
|
||||
UpdatePromoGroupResponse,
|
||||
UpdateReferralCommissionRequest,
|
||||
UpdateReferralCommissionResponse,
|
||||
UpdateRestrictionsRequest,
|
||||
UpdateRestrictionsResponse,
|
||||
UpdateSubscriptionRequest,
|
||||
@@ -68,6 +76,7 @@ from ..schemas.users import (
|
||||
UserAvailableTariffItem,
|
||||
UserAvailableTariffsResponse,
|
||||
UserDetailResponse,
|
||||
UserDevicesResponse,
|
||||
UserListItem,
|
||||
UserNodeUsageItem,
|
||||
UserNodeUsageResponse,
|
||||
@@ -82,7 +91,7 @@ from ..schemas.users import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/users', tags=['Cabinet Admin Users'])
|
||||
|
||||
@@ -157,13 +166,43 @@ def _build_subscription_info(subscription: Subscription, tariff_name: str | None
|
||||
|
||||
|
||||
async def _build_subscription_info_async(db: AsyncSession, subscription: Subscription) -> UserSubscriptionInfo:
|
||||
"""Build UserSubscriptionInfo from Subscription model, fetching tariff name asynchronously."""
|
||||
"""Build UserSubscriptionInfo from Subscription model, fetching tariff name and traffic purchases."""
|
||||
tariff_name = None
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
tariff_name = tariff.name
|
||||
return _build_subscription_info(subscription, tariff_name=tariff_name)
|
||||
|
||||
# Fetch traffic purchases
|
||||
now = datetime.utcnow()
|
||||
tp_query = (
|
||||
select(TrafficPurchase)
|
||||
.where(TrafficPurchase.subscription_id == subscription.id)
|
||||
.order_by(TrafficPurchase.created_at.desc())
|
||||
)
|
||||
tp_result = await db.execute(tp_query)
|
||||
purchases = tp_result.scalars().all()
|
||||
|
||||
traffic_purchase_items = []
|
||||
for p in purchases:
|
||||
delta = p.expires_at - now
|
||||
days_remaining = max(0, delta.days)
|
||||
is_expired = now >= p.expires_at
|
||||
traffic_purchase_items.append(
|
||||
TrafficPurchaseItem(
|
||||
id=p.id,
|
||||
traffic_gb=p.traffic_gb,
|
||||
expires_at=p.expires_at,
|
||||
created_at=p.created_at,
|
||||
days_remaining=days_remaining,
|
||||
is_expired=is_expired,
|
||||
)
|
||||
)
|
||||
|
||||
info = _build_subscription_info(subscription, tariff_name=tariff_name)
|
||||
info.purchased_traffic_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
info.traffic_purchases = traffic_purchase_items
|
||||
return info
|
||||
|
||||
|
||||
async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription: Subscription) -> dict:
|
||||
@@ -180,7 +219,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
logger.warning(f'Remnawave not configured, skipping panel sync for user {user.id}')
|
||||
logger.warning('Remnawave not configured, skipping panel sync for user', user_id=user.id)
|
||||
return {'skipped': True, 'reason': 'Remnawave not configured'}
|
||||
|
||||
is_active = (
|
||||
@@ -198,12 +237,16 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
description = settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(subscription)
|
||||
@@ -213,7 +256,15 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
async with service.get_api_client() as api:
|
||||
panel_uuid = user.remnawave_uuid
|
||||
|
||||
# Try to find existing user
|
||||
# Try to find existing user by UUID first
|
||||
if panel_uuid:
|
||||
existing_user = await api.get_user_by_uuid(panel_uuid)
|
||||
if not existing_user:
|
||||
logger.warning('User has stale remnawave_uuid clearing', user_id=user.id, panel_uuid=panel_uuid)
|
||||
panel_uuid = None
|
||||
user.remnawave_uuid = None
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_uuid and user.telegram_id:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
@@ -221,6 +272,14 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
# Fallback: search by email (for OAuth users without telegram_id)
|
||||
if not panel_uuid and user.email:
|
||||
existing_users = await api.get_user_by_email(user.email)
|
||||
if existing_users:
|
||||
panel_uuid = existing_users[0].uuid
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
if panel_uuid:
|
||||
# Update existing user
|
||||
update_kwargs = {
|
||||
@@ -240,7 +299,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
try:
|
||||
await api.update_user(**update_kwargs)
|
||||
changes['action'] = 'updated'
|
||||
logger.info(f'Updated user {user.id} in Remnawave panel')
|
||||
logger.info('Updated user in Remnawave panel', user_id=user.id)
|
||||
except Exception as update_error:
|
||||
if hasattr(update_error, 'status_code') and update_error.status_code == 404:
|
||||
panel_uuid = None # Will create new
|
||||
@@ -256,6 +315,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'telegram_id': user.telegram_id,
|
||||
'email': user.email,
|
||||
'description': description,
|
||||
'active_internal_squads': subscription.connected_squads or [],
|
||||
}
|
||||
@@ -268,7 +328,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
subscription.subscription_url = new_panel_user.subscription_url
|
||||
changes['action'] = 'created'
|
||||
changes['panel_uuid'] = new_panel_user.uuid
|
||||
logger.info(f'Created user {user.id} in Remnawave panel: {new_panel_user.uuid}')
|
||||
logger.info('Created user in Remnawave panel', user_id=user.id, uuid=new_panel_user.uuid)
|
||||
|
||||
user.last_remnawave_sync = datetime.utcnow()
|
||||
await db.commit()
|
||||
@@ -276,7 +336,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
return changes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error syncing user {user.id} to panel: {e}')
|
||||
logger.error('Error syncing user to panel', user_id=user.id, error=e)
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
@@ -612,15 +672,30 @@ async def get_user_panel_info(
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured or not user.telegram_id:
|
||||
if not service.is_configured:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if not panel_users:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
panel_user = None
|
||||
|
||||
panel_user = panel_users[0]
|
||||
# Try by UUID first (works for all users including OAuth)
|
||||
if user.remnawave_uuid:
|
||||
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_user and user.telegram_id:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
|
||||
# Fallback: search by email (OAuth users)
|
||||
if not panel_user and user.email:
|
||||
panel_users_by_email = await api.get_user_by_email(user.email)
|
||||
if panel_users_by_email:
|
||||
panel_user = panel_users_by_email[0]
|
||||
|
||||
if not panel_user:
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
# Resolve last connected node name via accessible nodes (lighter than get_all_nodes)
|
||||
last_node_name = None
|
||||
@@ -634,7 +709,7 @@ async def get_user_panel_info(
|
||||
last_node_name = node.node_name
|
||||
break
|
||||
except Exception:
|
||||
logger.warning(f'Failed to resolve node name for user {user_id}')
|
||||
logger.warning('Failed to resolve node name for user', user_id=user_id)
|
||||
|
||||
return UserPanelInfoResponse(
|
||||
found=True,
|
||||
@@ -653,7 +728,7 @@ async def get_user_panel_info(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting panel info for user {user_id}: {e}')
|
||||
logger.error('Error getting panel info for user', user_id=user_id, error=e)
|
||||
return UserPanelInfoResponse(found=False)
|
||||
|
||||
|
||||
@@ -737,7 +812,7 @@ async def get_user_node_usage(
|
||||
return UserNodeUsageResponse(items=items, categories=categories)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting node usage for user {user_id}: {e}')
|
||||
logger.error('Error getting node usage for user', user_id=user_id, error=e)
|
||||
return UserNodeUsageResponse(items=[])
|
||||
|
||||
|
||||
@@ -802,8 +877,12 @@ async def update_user_balance(
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
f'Admin {admin.id} updated balance for user {user_id}: '
|
||||
f'{old_balance} -> {user.balance_kopeks} ({request.amount_kopeks:+d})'
|
||||
'Admin updated balance for user',
|
||||
admin_id=admin.id,
|
||||
user_id=user_id,
|
||||
old_balance=old_balance,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
amount_kopeks=format(request.amount_kopeks, '+d'),
|
||||
)
|
||||
|
||||
return UpdateBalanceResponse(
|
||||
@@ -887,7 +966,7 @@ async def update_user_subscription(
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, new_sub)
|
||||
|
||||
logger.info(f'Admin {admin.id} created subscription for user {user_id}')
|
||||
logger.info('Admin created subscription for user', admin_id=admin.id, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
@@ -914,7 +993,9 @@ async def update_user_subscription(
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(f'Admin {admin.id} extended subscription for user {user_id} by {request.days} days')
|
||||
logger.info(
|
||||
'Admin extended subscription for user by days', admin_id=admin.id, user_id=user_id, days=request.days
|
||||
)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
@@ -941,7 +1022,7 @@ async def update_user_subscription(
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(f'Admin {admin.id} set end_date for user {user_id} subscription')
|
||||
logger.info('Admin set end_date for user subscription', admin_id=admin.id, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
@@ -975,7 +1056,7 @@ async def update_user_subscription(
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(f'Admin {admin.id} changed tariff for user {user_id} to {tariff.name}')
|
||||
logger.info('Admin changed tariff for user to', admin_id=admin.id, user_id=user_id, tariff_name=tariff.name)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
@@ -996,7 +1077,7 @@ async def update_user_subscription(
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated traffic for user {user_id}')
|
||||
logger.info('Admin updated traffic for user', admin_id=admin.id, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
@@ -1016,7 +1097,7 @@ async def update_user_subscription(
|
||||
await db.refresh(subscription)
|
||||
|
||||
state = 'enabled' if request.autopay_enabled else 'disabled'
|
||||
logger.info(f'Admin {admin.id} {state} autopay for user {user_id}')
|
||||
logger.info('Admin autopay for user', admin_id=admin.id, state=state, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
@@ -1033,7 +1114,7 @@ async def update_user_subscription(
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(f'Admin {admin.id} cancelled subscription for user {user_id}')
|
||||
logger.info('Admin cancelled subscription for user', admin_id=admin.id, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
@@ -1052,7 +1133,7 @@ async def update_user_subscription(
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(f'Admin {admin.id} activated subscription for user {user_id}')
|
||||
logger.info('Admin activated subscription for user', admin_id=admin.id, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
@@ -1060,6 +1141,119 @@ async def update_user_subscription(
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'add_traffic':
|
||||
if not request.traffic_gb:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='traffic_gb parameter is required for add_traffic action',
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import add_subscription_traffic
|
||||
|
||||
await add_subscription_traffic(db, subscription, request.traffic_gb)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
message=f'Added {request.traffic_gb} GB traffic (30 days)',
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'remove_traffic':
|
||||
if not request.traffic_purchase_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='traffic_purchase_id parameter is required for remove_traffic action',
|
||||
)
|
||||
|
||||
# Find the traffic purchase
|
||||
tp_query = select(TrafficPurchase).where(
|
||||
TrafficPurchase.id == request.traffic_purchase_id,
|
||||
TrafficPurchase.subscription_id == subscription.id,
|
||||
)
|
||||
tp_result = await db.execute(tp_query)
|
||||
traffic_purchase = tp_result.scalar_one_or_none()
|
||||
if not traffic_purchase:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Traffic purchase not found',
|
||||
)
|
||||
|
||||
removed_gb = traffic_purchase.traffic_gb
|
||||
|
||||
# Decrement counters
|
||||
subscription.traffic_limit_gb = max(0, subscription.traffic_limit_gb - removed_gb)
|
||||
current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
|
||||
subscription.purchased_traffic_gb = max(0, current_purchased - removed_gb)
|
||||
|
||||
# Delete the purchase record
|
||||
await db.delete(traffic_purchase)
|
||||
|
||||
# Recalculate traffic_reset_at from remaining active purchases
|
||||
now = datetime.utcnow()
|
||||
remaining_query = select(TrafficPurchase).where(
|
||||
TrafficPurchase.subscription_id == subscription.id,
|
||||
TrafficPurchase.expires_at > now,
|
||||
TrafficPurchase.id != request.traffic_purchase_id,
|
||||
)
|
||||
remaining_result = await db.execute(remaining_query)
|
||||
remaining_purchases = remaining_result.scalars().all()
|
||||
|
||||
if remaining_purchases:
|
||||
subscription.traffic_reset_at = min(p.expires_at for p in remaining_purchases)
|
||||
else:
|
||||
subscription.traffic_reset_at = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(
|
||||
'Admin removed traffic purchase ( GB) for user',
|
||||
admin_id=admin.id,
|
||||
traffic_purchase_id=request.traffic_purchase_id,
|
||||
removed_gb=removed_gb,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
message=f'Removed {removed_gb} GB traffic package',
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
if request.action == 'set_device_limit':
|
||||
if request.device_limit is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='device_limit parameter is required for set_device_limit action',
|
||||
)
|
||||
|
||||
subscription.device_limit = request.device_limit
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
logger.info(
|
||||
'Admin set device limit to for user', admin_id=admin.id, device_limit=request.device_limit, user_id=user_id
|
||||
)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
success=True,
|
||||
message=f'Device limit set to {request.device_limit}',
|
||||
subscription=await _build_subscription_info_async(db, subscription),
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Unknown action: {request.action}',
|
||||
@@ -1140,6 +1334,11 @@ async def get_user_available_tariffs(
|
||||
price_per_day_kopeks=tariff.price_per_day_kopeks,
|
||||
min_days=tariff.min_days,
|
||||
max_days=tariff.max_days,
|
||||
device_price_kopeks=tariff.device_price_kopeks,
|
||||
max_device_limit=tariff.max_device_limit,
|
||||
traffic_topup_enabled=tariff.traffic_topup_enabled,
|
||||
traffic_topup_packages=tariff.traffic_topup_packages or {},
|
||||
max_topup_traffic_gb=tariff.max_topup_traffic_gb,
|
||||
is_available=is_available,
|
||||
requires_promo_group=requires_promo_group,
|
||||
)
|
||||
@@ -1197,7 +1396,7 @@ async def update_user_status(
|
||||
if request.reason:
|
||||
action += f' (reason: {request.reason})'
|
||||
|
||||
logger.info(f'Admin {admin.id} changed status for user {user_id}: {action}')
|
||||
logger.info('Admin changed status for user', admin_id=admin.id, user_id=user_id, action=action)
|
||||
|
||||
return UpdateUserStatusResponse(
|
||||
success=True,
|
||||
@@ -1262,8 +1461,11 @@ async def update_user_restrictions(
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
f'Admin {admin.id} updated restrictions for user {user_id}: '
|
||||
f'topup={user.restriction_topup}, subscription={user.restriction_subscription}'
|
||||
'Admin updated restrictions for user topup=, subscription',
|
||||
admin_id=admin.id,
|
||||
user_id=user_id,
|
||||
restriction_topup=user.restriction_topup,
|
||||
restriction_subscription=user.restriction_subscription,
|
||||
)
|
||||
|
||||
return UpdateRestrictionsResponse(
|
||||
@@ -1314,7 +1516,11 @@ async def update_user_promo_group(
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
f'Admin {admin.id} changed promo group for user {user_id}: {old_promo_group_id} -> {new_promo_group_id}'
|
||||
'Admin changed promo group for user',
|
||||
admin_id=admin.id,
|
||||
user_id=user_id,
|
||||
old_promo_group_id=old_promo_group_id,
|
||||
new_promo_group_id=new_promo_group_id,
|
||||
)
|
||||
|
||||
return UpdatePromoGroupResponse(
|
||||
@@ -1326,6 +1532,177 @@ async def update_user_promo_group(
|
||||
)
|
||||
|
||||
|
||||
# === Referral Commission ===
|
||||
|
||||
|
||||
@router.post('/{user_id}/referral-commission', response_model=UpdateReferralCommissionResponse)
|
||||
async def update_user_referral_commission(
|
||||
user_id: int,
|
||||
request: UpdateReferralCommissionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's individual referral commission percentage."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
old_commission = user.referral_commission_percent
|
||||
user.referral_commission_percent = request.commission_percent
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin changed referral commission for user',
|
||||
admin_id=admin.id,
|
||||
user_id=user_id,
|
||||
old_commission=old_commission,
|
||||
commission_percent=request.commission_percent,
|
||||
)
|
||||
|
||||
return UpdateReferralCommissionResponse(
|
||||
success=True,
|
||||
old_commission_percent=old_commission,
|
||||
new_commission_percent=request.commission_percent,
|
||||
message='Referral commission updated',
|
||||
)
|
||||
|
||||
|
||||
# === Devices ===
|
||||
|
||||
|
||||
@router.get('/{user_id}/devices', response_model=UserDevicesResponse)
|
||||
async def get_user_devices(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user devices from Remnawave panel."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
if not user.remnawave_uuid:
|
||||
return UserDevicesResponse()
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if not service.is_configured:
|
||||
return UserDevicesResponse()
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
response = await api.get_user_devices(user.remnawave_uuid)
|
||||
|
||||
devices = []
|
||||
for d in response.get('devices', []):
|
||||
hwid = d.get('hwid') or d.get('deviceId') or d.get('id')
|
||||
if not hwid:
|
||||
continue
|
||||
devices.append(
|
||||
DeviceInfo(
|
||||
hwid=hwid,
|
||||
platform=d.get('platform') or d.get('platformType') or '',
|
||||
device_model=d.get('deviceModel') or d.get('model') or d.get('name') or '',
|
||||
created_at=d.get('updatedAt') or d.get('lastSeen') or d.get('createdAt'),
|
||||
)
|
||||
)
|
||||
|
||||
device_limit = 0
|
||||
if user.subscription:
|
||||
device_limit = user.subscription.device_limit or 0
|
||||
|
||||
return UserDevicesResponse(
|
||||
devices=devices,
|
||||
total=response.get('total', len(devices)),
|
||||
device_limit=device_limit,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error fetching devices for user', user_id=user_id, error=e)
|
||||
return UserDevicesResponse()
|
||||
|
||||
|
||||
@router.delete('/{user_id}/devices/{hwid}', response_model=DeleteDeviceResponse)
|
||||
async def delete_user_device(
|
||||
user_id: int,
|
||||
hwid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a single device for user."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
if not user.remnawave_uuid:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='User has no panel account')
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
success = await api.remove_device(user.remnawave_uuid, hwid)
|
||||
|
||||
if success:
|
||||
logger.info('Admin deleted device for user', admin_id=admin.id, hwid=hwid, user_id=user_id)
|
||||
return DeleteDeviceResponse(success=True, message='Device deleted', deleted_hwid=hwid)
|
||||
return DeleteDeviceResponse(success=False, message='Failed to delete device')
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error deleting device for user', hwid=hwid, user_id=user_id, error=e)
|
||||
return DeleteDeviceResponse(success=False, message=str(e))
|
||||
|
||||
|
||||
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
|
||||
async def reset_user_devices(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all devices for user."""
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
if not user.remnawave_uuid:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='User has no panel account')
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
devices_info = await api.get_user_devices(user.remnawave_uuid)
|
||||
devices = devices_info.get('devices', [])
|
||||
total = len(devices)
|
||||
|
||||
if total == 0:
|
||||
return ResetDevicesResponse(success=True, message='No devices to reset', deleted_count=0)
|
||||
|
||||
deleted = 0
|
||||
for d in devices:
|
||||
device_hwid = d.get('hwid') or d.get('deviceId') or d.get('id')
|
||||
if device_hwid:
|
||||
try:
|
||||
await api.remove_device(user.remnawave_uuid, device_hwid)
|
||||
deleted += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info('Admin reset devices for user /', admin_id=admin.id, user_id=user_id, deleted=deleted, total=total)
|
||||
return ResetDevicesResponse(success=True, message=f'Deleted {deleted}/{total} devices', deleted_count=deleted)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error resetting devices for user', user_id=user_id, error=e)
|
||||
return ResetDevicesResponse(success=False, message=str(e))
|
||||
|
||||
|
||||
# === Delete User ===
|
||||
|
||||
|
||||
@@ -1359,7 +1736,7 @@ async def delete_user(
|
||||
action = 'permanently deleted'
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info(f'Admin {admin.id} {action} user {user_id}{reason_text}')
|
||||
logger.info('Admin user', admin_id=admin.id, action=action, user_id=user_id, reason_text=reason_text)
|
||||
|
||||
return DeleteUserResponse(
|
||||
success=True,
|
||||
@@ -1402,7 +1779,7 @@ async def full_delete_user(
|
||||
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info(f'Admin {admin.id} fully deleted user {user_id}{reason_text}')
|
||||
logger.info('Admin fully deleted user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
|
||||
|
||||
return FullDeleteUserResponse(
|
||||
success=success,
|
||||
@@ -1446,13 +1823,15 @@ async def reset_user_trial(
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for trial reset')
|
||||
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to disable Remnawave user during trial reset: {e}')
|
||||
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
|
||||
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
|
||||
subscription_id = user.subscription.id
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
|
||||
@@ -1463,7 +1842,7 @@ async def reset_user_trial(
|
||||
await db.commit()
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info(f'Admin {admin.id} reset trial for user {user_id}{reason_text}')
|
||||
logger.info('Admin reset trial for user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
|
||||
|
||||
return ResetTrialResponse(
|
||||
success=True,
|
||||
@@ -1515,14 +1894,16 @@ async def reset_user_subscription(
|
||||
subscription_service = SubscriptionService()
|
||||
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
if panel_deactivated:
|
||||
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for subscription reset')
|
||||
logger.info('Disabled Remnawave user for subscription reset', remnawave_uuid=user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
panel_error = str(e)
|
||||
logger.warning(f'Failed to disable Remnawave user during subscription reset: {e}')
|
||||
logger.warning('Failed to disable Remnawave user during subscription reset', error=e)
|
||||
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
|
||||
subscription_id = user.subscription.id
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
|
||||
@@ -1530,7 +1911,7 @@ async def reset_user_subscription(
|
||||
await db.commit()
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info(f'Admin {admin.id} reset subscription for user {user_id}{reason_text}')
|
||||
logger.info('Admin reset subscription for user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
|
||||
|
||||
return ResetSubscriptionResponse(
|
||||
success=True,
|
||||
@@ -1575,10 +1956,10 @@ async def disable_user(
|
||||
subscription_service = SubscriptionService()
|
||||
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
if panel_deactivated:
|
||||
logger.info(f'Disabled Remnawave user {user.remnawave_uuid}')
|
||||
logger.info('Disabled Remnawave user', remnawave_uuid=user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
panel_error = str(e)
|
||||
logger.warning(f'Failed to disable Remnawave user: {e}')
|
||||
logger.warning('Failed to disable Remnawave user', error=e)
|
||||
|
||||
# Deactivate subscription in bot database
|
||||
if user.subscription:
|
||||
@@ -1586,7 +1967,7 @@ async def disable_user(
|
||||
|
||||
await deactivate_subscription(db, user.subscription)
|
||||
subscription_deactivated = True
|
||||
logger.info(f'Deactivated subscription for user {user_id}')
|
||||
logger.info('Deactivated subscription for user', user_id=user_id)
|
||||
|
||||
# Block user account
|
||||
user.status = UserStatus.BLOCKED.value
|
||||
@@ -1594,7 +1975,7 @@ async def disable_user(
|
||||
await db.commit()
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info(f'Admin {admin.id} disabled user {user_id}{reason_text}')
|
||||
logger.info('Admin disabled user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
|
||||
|
||||
return DisableUserResponse(
|
||||
success=True,
|
||||
@@ -1754,11 +2135,27 @@ async def get_user_sync_status(
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
if service.is_configured and user.telegram_id:
|
||||
if service.is_configured:
|
||||
async with service.get_api_client() as api:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
panel_user = None
|
||||
|
||||
# Try by UUID first (works for all users including OAuth)
|
||||
if user.remnawave_uuid:
|
||||
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_user and user.telegram_id:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
|
||||
# Fallback: search by email (OAuth users)
|
||||
if not panel_user and user.email:
|
||||
panel_users_by_email = await api.get_user_by_email(user.email)
|
||||
if panel_users_by_email:
|
||||
panel_user = panel_users_by_email[0]
|
||||
|
||||
if panel_user:
|
||||
panel_found = True
|
||||
panel_status = panel_user.status.value if panel_user.status else None
|
||||
panel_expire_at = panel_user.expire_at
|
||||
@@ -1825,7 +2222,7 @@ async def get_user_sync_status(
|
||||
differences.append(f'Squads mismatch ({", ".join(squad_diff_parts)})')
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get panel data for user {user_id}: {e}')
|
||||
logger.warning('Failed to get panel data for user', user_id=user_id, error=e)
|
||||
differences.append(f'Error fetching panel data: {e!s}')
|
||||
|
||||
return PanelSyncStatusResponse(
|
||||
@@ -1884,27 +2281,30 @@ async def sync_user_from_panel(
|
||||
errors = []
|
||||
panel_info = None
|
||||
|
||||
# Email-only users cannot be synced from panel by telegram_id
|
||||
if not user.telegram_id:
|
||||
return SyncFromPanelResponse(
|
||||
success=False,
|
||||
message='Cannot sync email-only user',
|
||||
errors=["Email-only users don't have telegram_id for panel lookup"],
|
||||
)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
# Find user in panel
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
# Find user in panel: UUID → telegram_id → email
|
||||
panel_user = None
|
||||
|
||||
if not panel_users:
|
||||
if user.remnawave_uuid:
|
||||
panel_user = await api.get_user_by_uuid(user.remnawave_uuid)
|
||||
|
||||
if not panel_user and user.telegram_id:
|
||||
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if panel_users:
|
||||
panel_user = panel_users[0]
|
||||
|
||||
if not panel_user and user.email:
|
||||
panel_users_by_email = await api.get_user_by_email(user.email)
|
||||
if panel_users_by_email:
|
||||
panel_user = panel_users_by_email[0]
|
||||
|
||||
if not panel_user:
|
||||
return SyncFromPanelResponse(
|
||||
success=False,
|
||||
message='User not found in panel',
|
||||
errors=['No user with this telegram_id found in Remnawave panel'],
|
||||
errors=['No user found in Remnawave panel by UUID, telegram_id, or email'],
|
||||
)
|
||||
|
||||
panel_user = panel_users[0]
|
||||
|
||||
# Build panel info
|
||||
active_squads = []
|
||||
if hasattr(panel_user, 'active_internal_squads') and panel_user.active_internal_squads:
|
||||
@@ -2032,7 +2432,9 @@ async def sync_user_from_panel(
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.id} synced user {user_id} from panel. Changes: {list(changes.keys())}')
|
||||
logger.info(
|
||||
'Admin synced user from panel. Changes', admin_id=admin.id, user_id=user_id, value=list(changes.keys())
|
||||
)
|
||||
|
||||
return SyncFromPanelResponse(
|
||||
success=True,
|
||||
@@ -2045,7 +2447,7 @@ async def sync_user_from_panel(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error syncing user {user_id} from panel: {e}')
|
||||
logger.error('Error syncing user from panel', user_id=user_id, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Sync error: {e!s}',
|
||||
@@ -2113,19 +2515,31 @@ async def sync_user_to_panel(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
description = settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(sub)
|
||||
traffic_limit_bytes = sub.traffic_limit_gb * (1024**3) if sub.traffic_limit_gb > 0 else 0
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
# Try to find existing user in panel
|
||||
# Validate existing UUID
|
||||
if panel_uuid:
|
||||
existing_user = await api.get_user_by_uuid(panel_uuid)
|
||||
if not existing_user:
|
||||
logger.warning('User has stale remnawave_uuid clearing', user_id=user.id, panel_uuid=panel_uuid)
|
||||
panel_uuid = None
|
||||
user.remnawave_uuid = None
|
||||
|
||||
# Fallback: search by telegram_id
|
||||
if not panel_uuid and user.telegram_id:
|
||||
existing_users = await api.get_user_by_telegram_id(user.telegram_id)
|
||||
if existing_users:
|
||||
@@ -2133,6 +2547,14 @@ async def sync_user_to_panel(
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
# Fallback: search by email (OAuth users)
|
||||
if not panel_uuid and user.email:
|
||||
existing_users = await api.get_user_by_email(user.email)
|
||||
if existing_users:
|
||||
panel_uuid = existing_users[0].uuid
|
||||
user.remnawave_uuid = panel_uuid
|
||||
changes['remnawave_uuid_discovered'] = panel_uuid
|
||||
|
||||
if panel_uuid:
|
||||
# Update existing user
|
||||
update_kwargs = {'uuid': panel_uuid}
|
||||
@@ -2178,6 +2600,7 @@ async def sync_user_to_panel(
|
||||
'traffic_limit_bytes': traffic_limit_bytes,
|
||||
'traffic_limit_strategy': TrafficLimitStrategy.MONTH,
|
||||
'telegram_id': user.telegram_id,
|
||||
'email': user.email,
|
||||
'description': description,
|
||||
'active_internal_squads': sub.connected_squads or [],
|
||||
}
|
||||
@@ -2202,7 +2625,7 @@ async def sync_user_to_panel(
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.id} synced user {user_id} to panel. Action: {action}')
|
||||
logger.info('Admin synced user to panel. Action', admin_id=admin.id, user_id=user_id, action=action)
|
||||
|
||||
return SyncToPanelResponse(
|
||||
success=True,
|
||||
@@ -2216,7 +2639,7 @@ async def sync_user_to_panel(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error syncing user {user_id} to panel: {e}')
|
||||
logger.error('Error syncing user to panel', user_id=user_id, error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Sync error: {e!s}',
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
+110
-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
|
||||
@@ -68,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'])
|
||||
|
||||
@@ -157,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
|
||||
@@ -207,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)
|
||||
@@ -227,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
|
||||
|
||||
@@ -274,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,
|
||||
@@ -283,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
|
||||
@@ -297,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(
|
||||
@@ -340,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,
|
||||
@@ -349,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(
|
||||
@@ -484,7 +493,7 @@ 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):
|
||||
@@ -511,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,
|
||||
)
|
||||
|
||||
# Создать пользователя
|
||||
@@ -533,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()
|
||||
@@ -578,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)
|
||||
@@ -723,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,
|
||||
@@ -969,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
|
||||
@@ -1000,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()
|
||||
@@ -1042,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,6 +23,7 @@ from app.services.payment_verification_service import (
|
||||
method_display_name,
|
||||
run_manual_check,
|
||||
)
|
||||
from app.utils.currency_converter import currency_converter
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.balance import (
|
||||
@@ -40,7 +41,7 @@ from ..schemas.balance import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/balance', tags=['Cabinet Balance'])
|
||||
|
||||
@@ -248,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',
|
||||
@@ -278,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',
|
||||
@@ -286,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(
|
||||
@@ -297,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',
|
||||
@@ -381,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,
|
||||
@@ -684,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'])
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""OAuth 2.0 authentication routes for cabinet."""
|
||||
|
||||
import logging
|
||||
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
|
||||
@@ -27,7 +27,7 @@ from ..schemas.auth import AuthResponse
|
||||
from .auth import _create_auth_response, _store_refresh_token
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
|
||||
|
||||
@@ -120,7 +120,7 @@ async def oauth_callback(
|
||||
try:
|
||||
token_data = await oauth_provider.exchange_code(request.code)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth code exchange failed for %s: %s', provider, exc)
|
||||
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',
|
||||
@@ -130,7 +130,7 @@ async def oauth_callback(
|
||||
try:
|
||||
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
|
||||
except Exception as exc:
|
||||
logger.error('OAuth user info fetch failed for %s: %s', provider, exc)
|
||||
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',
|
||||
@@ -139,7 +139,7 @@ async def oauth_callback(
|
||||
# 5. Find user by provider ID
|
||||
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if user:
|
||||
logger.info('OAuth login via %s for existing user %s', provider, user.id)
|
||||
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
|
||||
@@ -147,7 +147,7 @@ async def oauth_callback(
|
||||
user = await get_user_by_email(db, user_info.email)
|
||||
if user:
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
logger.info('OAuth login via %s linked to existing email user %s', provider, user.id)
|
||||
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
|
||||
@@ -161,5 +161,5 @@ async def oauth_callback(
|
||||
last_name=user_info.last_name,
|
||||
username=user_info.username,
|
||||
)
|
||||
logger.info('OAuth new user created via %s with id=%s', provider, user.id)
|
||||
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'])
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -1109,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:
|
||||
@@ -1197,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
|
||||
@@ -1229,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(
|
||||
@@ -1244,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:
|
||||
@@ -1253,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:
|
||||
@@ -1272,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)
|
||||
|
||||
@@ -1537,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',
|
||||
@@ -1582,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',
|
||||
@@ -1644,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:
|
||||
@@ -1662,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,
|
||||
@@ -1670,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,
|
||||
@@ -1702,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,
|
||||
@@ -1716,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',
|
||||
@@ -1898,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,
|
||||
@@ -2005,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:
|
||||
@@ -2019,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)
|
||||
|
||||
@@ -2081,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:
|
||||
@@ -2110,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',
|
||||
@@ -2224,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,
|
||||
@@ -2274,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:
|
||||
@@ -2308,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,
|
||||
@@ -2331,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='Не удалось обработать покупку устройств',
|
||||
@@ -2407,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(
|
||||
@@ -2449,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}
|
||||
|
||||
@@ -2524,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}
|
||||
|
||||
@@ -2650,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 {}
|
||||
|
||||
|
||||
@@ -2793,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
|
||||
|
||||
@@ -2842,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
|
||||
@@ -2951,12 +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}')
|
||||
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()
|
||||
@@ -2987,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
|
||||
@@ -3010,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}'
|
||||
|
||||
@@ -3216,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
|
||||
@@ -3231,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)
|
||||
|
||||
@@ -3566,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,
|
||||
@@ -3610,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',
|
||||
@@ -3669,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,
|
||||
@@ -3678,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',
|
||||
@@ -3752,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
|
||||
|
||||
@@ -3836,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
|
||||
@@ -3854,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
|
||||
|
||||
@@ -3872,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}'
|
||||
@@ -4306,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
|
||||
@@ -4316,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)
|
||||
@@ -4346,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,
|
||||
@@ -4445,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'
|
||||
@@ -4579,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)
|
||||
@@ -4720,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
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -33,6 +34,18 @@ class TrafficUsageResponse(BaseModel):
|
||||
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
|
||||
|
||||
@@ -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):
|
||||
@@ -285,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')
|
||||
@@ -348,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."""
|
||||
|
||||
@@ -441,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'])
|
||||
|
||||
|
||||
+68
-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
|
||||
@@ -162,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 = ''
|
||||
|
||||
@@ -181,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)
|
||||
@@ -339,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
|
||||
@@ -401,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'
|
||||
@@ -409,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
|
||||
@@ -508,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 = ''
|
||||
@@ -531,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 коп вверх)
|
||||
@@ -742,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')
|
||||
@@ -1051,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]:
|
||||
@@ -1095,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:
|
||||
@@ -1182,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
|
||||
@@ -1288,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:
|
||||
@@ -1308,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
|
||||
@@ -1359,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]:
|
||||
@@ -1435,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
|
||||
|
||||
@@ -1518,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:
|
||||
@@ -1532,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'
|
||||
@@ -1545,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 - главный переключатель платной активации
|
||||
# Если выключен - триал бесплатный, независимо от цены
|
||||
@@ -1620,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
|
||||
|
||||
@@ -1740,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)
|
||||
@@ -1835,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
|
||||
|
||||
@@ -2190,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:
|
||||
@@ -2224,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:
|
||||
@@ -2448,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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+22
-26
@@ -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,7 +481,7 @@ 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))
|
||||
|
||||
logger.info('Изменен порядок тарифов: %s', tariff_order)
|
||||
logger.info('Изменен порядок тарифов', tariff_order=tariff_order)
|
||||
|
||||
|
||||
async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
|
||||
@@ -522,7 +517,8 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
|
||||
# Тариф уже существует — НЕ перезаписываем настройки из конфига.
|
||||
# Админ управляет тарифом через кабинет, синхронизация не нужна.
|
||||
logger.info(
|
||||
"Дефолтный тариф 'Стандартный' (id=%s) уже существует, пропускаем sync из конфига", existing_tariff.id
|
||||
"Дефолтный тариф 'Стандартный' (id=) уже существует, пропускаем sync из конфига",
|
||||
existing_tariff_id=existing_tariff.id,
|
||||
)
|
||||
return existing_tariff
|
||||
|
||||
@@ -544,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
|
||||
@@ -574,7 +570,7 @@ async def load_period_prices_from_db(db: AsyncSession) -> None:
|
||||
return
|
||||
|
||||
if not tariff.period_prices:
|
||||
logger.warning("Тариф '%s' (id=%s) найден, но period_prices пуст", tariff.name, tariff.id)
|
||||
logger.warning("Тариф '' (id=) найден, но period_prices пуст", tariff_name=tariff.name, tariff_id=tariff.id)
|
||||
return
|
||||
|
||||
# Преобразуем строковые ключи в int
|
||||
@@ -588,10 +584,10 @@ async def load_period_prices_from_db(db: AsyncSession) -> None:
|
||||
{f'{d}д': f'{p // 100}₽' for d, p in period_prices.items()},
|
||||
)
|
||||
else:
|
||||
logger.warning("Тариф '%s' не имеет активных периодов (все цены = 0)", tariff.name)
|
||||
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:
|
||||
@@ -604,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,
|
||||
|
||||
+82
-59
@@ -1,8 +1,8 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
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,7 +1254,7 @@ 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 ---
|
||||
@@ -1267,7 +1287,7 @@ async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str
|
||||
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(f'Linked {provider} (id={provider_id}) to user {user.id}')
|
||||
logger.info('Linked (id=) to user', provider=provider, provider_id=provider_id, user_id=user.id)
|
||||
|
||||
|
||||
async def create_user_by_oauth(
|
||||
@@ -1283,6 +1303,7 @@ async def create_user_by_oauth(
|
||||
) -> 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)
|
||||
@@ -1297,7 +1318,7 @@ async def create_user_by_oauth(
|
||||
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=language,
|
||||
language=normalized_language,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
@@ -1312,7 +1333,9 @@ async def create_user_by_oauth(
|
||||
await db.refresh(user)
|
||||
|
||||
user.promo_group = default_group
|
||||
logger.info(f'Created OAuth user via {provider} (provider_id={provider_id}) with id={user.id}')
|
||||
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
|
||||
@@ -1329,6 +1352,6 @@ async def create_user_by_oauth(
|
||||
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
|
||||
|
||||
@@ -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():
|
||||
|
||||
+30
-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
|
||||
|
||||
|
||||
@@ -1137,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)
|
||||
|
||||
# Тариф (для режима продаж "Тарифы")
|
||||
@@ -1151,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:
|
||||
@@ -1180,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'
|
||||
|
||||
@@ -1228,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
|
||||
@@ -1253,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():
|
||||
@@ -1768,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):
|
||||
@@ -2058,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')
|
||||
|
||||
|
||||
@@ -2398,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__ = (
|
||||
|
||||
+239
-205
File diff suppressed because it is too large
Load Diff
Vendored
+4
-4
@@ -4,13 +4,13 @@ Ban System API Client.
|
||||
Client for interacting with the BedolagaBan monitoring system.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class BanSystemAPIError(Exception):
|
||||
@@ -78,7 +78,7 @@ class BanSystemAPI:
|
||||
response_text = await response.text()
|
||||
|
||||
if response.status >= 400:
|
||||
logger.error(f'Ban System API error: {response.status} - {response_text}')
|
||||
logger.error('Ban System API error', status=response.status, response_text=response_text)
|
||||
raise BanSystemAPIError(
|
||||
message=f'API error {response.status}: {response_text}',
|
||||
status_code=response.status,
|
||||
@@ -93,7 +93,7 @@ class BanSystemAPI:
|
||||
return {}
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f'Ban System API connection error: {e}')
|
||||
logger.error('Ban System API connection error', error=e)
|
||||
raise BanSystemAPIError(message=f'Connection error: {e!s}', status_code=None, response_data=None)
|
||||
except TimeoutError:
|
||||
logger.error('Ban System API request timeout')
|
||||
|
||||
Vendored
+8
-8
@@ -1,14 +1,14 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class CryptoBotService:
|
||||
@@ -49,11 +49,11 @@ class CryptoBotService:
|
||||
|
||||
if response.status == 200 and response_data.get('ok'):
|
||||
return response_data.get('result')
|
||||
logger.error(f'CryptoBot API ошибка: {response_data}')
|
||||
logger.error('CryptoBot API ошибка', response_data=response_data)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка запроса к CryptoBot API: {e}')
|
||||
logger.error('Ошибка запроса к CryptoBot API', error=e)
|
||||
return None
|
||||
|
||||
async def get_me(self) -> dict[str, Any] | None:
|
||||
@@ -81,7 +81,7 @@ class CryptoBotService:
|
||||
result = await self._make_request('POST', 'createInvoice', data)
|
||||
|
||||
if result:
|
||||
logger.info(f'Создан CryptoBot invoice {result.get("invoice_id")} на {amount} {asset}')
|
||||
logger.info('Создан CryptoBot invoice на', get=result.get('invoice_id'), amount=amount, asset=asset)
|
||||
|
||||
return result
|
||||
|
||||
@@ -140,7 +140,7 @@ class CryptoBotService:
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка проверки подписи CryptoBot webhook: {e}')
|
||||
logger.error('Ошибка проверки подписи CryptoBot webhook', error=e)
|
||||
return False
|
||||
|
||||
async def process_webhook(self, webhook_data: dict[str, Any]) -> dict[str, Any] | None:
|
||||
@@ -161,9 +161,9 @@ class CryptoBotService:
|
||||
'payment_system': 'cryptobot',
|
||||
}
|
||||
|
||||
logger.warning(f'Неизвестный тип CryptoBot webhook: {update_type}')
|
||||
logger.warning('Неизвестный тип CryptoBot webhook', update_type=update_type)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка обработки CryptoBot webhook: {e}')
|
||||
logger.error('Ошибка обработки CryptoBot webhook', error=e)
|
||||
return None
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user