Compare commits
186 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2044cecc6e | |||
| ffffccb389 | |||
| 28d263fc8d | |||
| bfef7cc629 | |||
| a696896d2c | |||
| fd2e419e8e | |||
| aaf0263fda | |||
| d4d5031cc2 | |||
| b2d7abf5bd | |||
| 0f9f843236 | |||
| cab425cfac | |||
| 0da0c5547d | |||
| 988d0e5c2f | |||
| 1ce91749aa | |||
| 731eb24364 | |||
| a15403b8b6 | |||
| ff8f3d02cf | |||
| 69f57eddd6 | |||
| fe567fffa8 | |||
| f300e07ce2 | |||
| 628997fb48 | |||
| 3dc0b93bdf | |||
| bea9da96d4 | |||
| 388fc7ee67 | |||
| f6b6e22a95 | |||
| 60c4fe2e23 | |||
| c1da8a4dba | |||
| af6686ccfa | |||
| 8893fc128e | |||
| 4598c2785a | |||
| 5a7dd3f164 | |||
| bc7d0612f1 | |||
| 1646f04bde | |||
| 3fee54f657 | |||
| a594a0f79f | |||
| 3642462670 | |||
| 26efb157e4 | |||
| c7ce80e882 | |||
| 83e04a2e93 | |||
| 351ebcf9eb | |||
| e5fa45f74f | |||
| 25f014fd89 | |||
| 6f473defef | |||
| 59fb08c3ea | |||
| 295d2e877e | |||
| 711ec344c6 | |||
| 978726a785 | |||
| 6a50013c21 | |||
| 1bb939f63a | |||
| 6feec1eaa8 | |||
| fae6f71def | |||
| 5a269b249e | |||
| 0b3b2e5dc5 | |||
| 314c892c4d | |||
| 1bc9074c1b | |||
| 3af07ff627 | |||
| 2aead9a68b | |||
| a7db469fd7 | |||
| a47ef67090 | |||
| 8375d7ecc5 | |||
| 751e312f28 | |||
| 4eaaf06a17 | |||
| 1930a9dcde | |||
| b876c6dd0b | |||
| d15b69710c | |||
| 708bb9eec7 | |||
| 97b3f899d1 | |||
| 5ee45f97d1 | |||
| d4c4a8a211 | |||
| 205c8d987d | |||
| ebe508302b | |||
| c20355b06d | |||
| 50a931ec36 | |||
| 115c0c84c0 | |||
| 973b3d3d3f | |||
| 2ef6185715 | |||
| ed4624c664 | |||
| 1b6bbc7131 | |||
| 1f4430f3af | |||
| 67f3547ae2 | |||
| 49f64cacd7 | |||
| 9101c98244 | |||
| 311f278123 | |||
| 493f315a65 | |||
| 18c2477173 | |||
| 6e28a1a22b | |||
| be00256618 | |||
| 7f693f2b58 | |||
| c3bf0dc0fd | |||
| d651a6c02f | |||
| d7039d75a4 | |||
| 6409b0c023 | |||
| af31c551d2 | |||
| a38dfcb75a | |||
| b7b83abb72 | |||
| f076269c32 | |||
| 8d16935c1c | |||
| 49d8de76a2 | |||
| b4d8cabbd8 | |||
| a7f3d652c5 | |||
| 38f3a9a16a | |||
| f7d33a7d2b | |||
| bd11801467 | |||
| e512e5fe6e | |||
| 799c83dd84 | |||
| 4cc18cbc9a | |||
| 4645be53cb | |||
| 79ea398d1d | |||
| 30b1402b54 | |||
| 15d848c1ca | |||
| c9877a3cbe | |||
| 68499ee043 | |||
| bdb61613de | |||
| 59383bdbd8 | |||
| 5d4a94b8ce | |||
| 0c07812ecc | |||
| eb9dba3f47 | |||
| 6c4e035146 | |||
| e78b1040a5 | |||
| b6c7f91a7c | |||
| e998059d81 | |||
| 764e063bfe | |||
| 784616b349 | |||
| b4b10c998c | |||
| 366df18c54 | |||
| 7883efc3d6 | |||
| 6881d97bbb | |||
| 90278f1f5f | |||
| df5b1a072d | |||
| 10e231e52e | |||
| 7c20fde4e8 | |||
| fcf3a2c806 | |||
| 88997492c3 | |||
| 327d4f4d15 | |||
| cf7cc5a84e | |||
| 28f524b762 | |||
| c4dc43e054 | |||
| 767e965028 | |||
| d39063b22f | |||
| ea5d932476 | |||
| acc1323a54 | |||
| 58bfaeaddb | |||
| df5415f30b | |||
| 330d670f3f | |||
| 41cb122a2f | |||
| 1b3e6f2f11 | |||
| 6fa49485d9 | |||
| 71aa023133 | |||
| e567c02658 | |||
| f393dc0840 | |||
| 5dc4b0ec15 | |||
| e68760cc66 | |||
| d9552799c1 | |||
| c75ec0b22a | |||
| 27309f53d9 | |||
| 4193f717ee | |||
| d297985b0b | |||
| 6dcf3a9f0d | |||
| 094609005a | |||
| eb18994b7d | |||
| ff21b27b98 | |||
| 4247981c98 | |||
| c30972f6a7 | |||
| 7628fb9f6e | |||
| 4c48eadebc | |||
| 6ea3860a2f | |||
| 1b8ef69a1b | |||
| 9d710050ad | |||
| 491a7e1c42 | |||
| 7eb8d4e153 | |||
| f63720467a | |||
| 516be6e600 | |||
| 0807a9ff19 | |||
| a93a32f3a7 | |||
| 68de66f526 | |||
| 15aba2b3db | |||
| fa78fa6d09 | |||
| 11f8af003f | |||
| 11ef714e0d | |||
| 909a4039c4 | |||
| bf646112df | |||
| 8a6650e57c | |||
| 25e8c9f8fc | |||
| 1f0fef114b | |||
| be6036e879 | |||
| bba85a309a |
+14
-6
@@ -116,10 +116,8 @@ BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновле
|
||||
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
|
||||
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
|
||||
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
# Channel subscription settings (channels are managed via admin panel)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
CHANNEL_LINK= # Опционально ссылка на канал
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
|
||||
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
|
||||
|
||||
@@ -371,7 +369,8 @@ REFERRAL_MINIMUM_TOPUP_KOPEKS=10000
|
||||
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000
|
||||
REFERRAL_INVITER_BONUS_KOPEKS=10000
|
||||
REFERRAL_COMMISSION_PERCENT=25
|
||||
|
||||
# Показывать раздел партнёрки в кабинете
|
||||
REFERRAL_PARTNER_SECTION_VISIBLE=true
|
||||
|
||||
# Уведомления
|
||||
REFERRAL_NOTIFICATIONS_ENABLED=true
|
||||
@@ -384,6 +383,8 @@ REFERRAL_WITHDRAWAL_ENABLED=false
|
||||
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS=50000
|
||||
# Интервал между запросами на вывод (дни)
|
||||
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS=30
|
||||
# Текст-подсказка для поля реквизитов при выводе (пустая строка = стандартный текст)
|
||||
REFERRAL_WITHDRAWAL_REQUISITES_TEXT=
|
||||
# Выводить только реферальный баланс (true) или весь баланс (false)
|
||||
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE=true
|
||||
# ID топика для уведомлений о заявках на вывод (0 = основной чат)
|
||||
@@ -629,6 +630,13 @@ FREEKASSA_WEBHOOK_PORT=8088
|
||||
FREEKASSA_PAYMENT_SYSTEM_ID=
|
||||
# Использовать API для создания заказов (обязательно для NSPK СБП)
|
||||
FREEKASSA_USE_API=false
|
||||
# Раздельные методы оплаты (отображаются как отдельные кнопки)
|
||||
# СБП (QR код) — i=44
|
||||
FREEKASSA_SBP_ENABLED=false
|
||||
FREEKASSA_SBP_DISPLAY_NAME=СБП (QR код)
|
||||
# Карты РФ — i=36
|
||||
FREEKASSA_CARD_ENABLED=false
|
||||
FREEKASSA_CARD_DISPLAY_NAME=Карта РФ
|
||||
|
||||
# ===== KASSA AI (api.fk.life) =====
|
||||
# Отдельная платёжная система, работает параллельно с Freekassa
|
||||
@@ -802,8 +810,6 @@ PRICE_ROUNDING_ENABLED=true
|
||||
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
|
||||
|
||||
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
|
||||
# Конфигурация приложений для гайда подключения
|
||||
APP_CONFIG_PATH=app-config.json
|
||||
ENABLE_DEEP_LINKS=true
|
||||
APP_CONFIG_CACHE_TTL=3600
|
||||
|
||||
@@ -845,6 +851,8 @@ VERSION_CHECK_INTERVAL_HOURS=1
|
||||
# ===== ЛОГИРОВАНИЕ =====
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/bot.log
|
||||
# ANSI-цвета в консоли (true — цветной вывод с Rich, false — plain-text)
|
||||
LOG_COLORS=true
|
||||
|
||||
# === Ротация логов ===
|
||||
# Включить новую систему ротации (по умолчанию старое поведение)
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
!uv.lock
|
||||
!requirements.txt
|
||||
!alembic.ini
|
||||
!app-config.json
|
||||
!release-please-config.json
|
||||
!.release-please-manifest.json
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.12.0"
|
||||
".": "3.20.1"
|
||||
}
|
||||
|
||||
+277
@@ -1,5 +1,282 @@
|
||||
# Changelog
|
||||
|
||||
## [3.20.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.20.0...v3.20.1) (2026-02-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* make migrations 0010/0011 idempotent, escape HTML in crash notification ([a696896](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a696896d2c4a3d0d6026398fcdc76ded9575375d))
|
||||
* prevent race condition expiring active daily subscriptions ([bfef7cc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfef7cc6296e296f17068e519469c3deaddc1b3b))
|
||||
|
||||
## [3.20.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.19.0...v3.20.0) (2026-02-25)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add separate Freekassa SBP and card payment methods ([0da0c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0da0c5547d0648a70f848fe77c13d583f4868a52))
|
||||
* add validation to animation config API ([a15403b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a15403b8b6e1ec1bb5c37fdde646e7790373e860))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* initialize logger in bot_configuration.py ([988d0e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/988d0e5c2f27538135d757187a0b6770f078b1d9))
|
||||
* remove gemini-effect and noise from allowed background types ([731eb24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/731eb2436428d0e12f1e5ccdebc72cd74fd7c65e))
|
||||
* resolve ruff lint errors (import sorting, unused variable) ([b2d7abf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b2d7abf5bd10a98fd7ad1da50b5072afc65a5b48))
|
||||
* resolve sync 404 errors, user deletion FK constraint, and device limit not sent to RemnaWave ([1ce9174](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ce91749aa12ffcefcf66bea714cea218739f3fe))
|
||||
|
||||
## [3.19.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.18.0...v3.19.0) (2026-02-25)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add granular user permissions (balance, subscription, promo_group, referral, send_offer) ([60c4fe2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/60c4fe2e239d8fef7726cac769711c8fcce789eb))
|
||||
* add per-channel disable settings and fix CHANNEL_REQUIRED_FOR_ALL bug ([3642462](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3642462670c876052aa668c1515af8c04234cb34))
|
||||
* add RBAC + ABAC permission system for admin cabinet ([3fee54f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3fee54f657dc6e0db1ec36697850ada2235e6968))
|
||||
* add resource_type and request body to audit log entries ([388fc7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/388fc7ee67f5fc0edf6b7b64b977e12a2d8f0566))
|
||||
* allow editing system roles ([f6b6e22](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f6b6e22a9528dc05b7fbfa80b63051a75c8e73cd))
|
||||
* capture query params in audit log details for all requests ([bea9da9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bea9da96d44965fcee5e2eba448960443152d4ea))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address RBAC review findings (CRITICAL + HIGH) ([1646f04](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1646f04bde47a08f3fd782b7831d40760bd1ba60))
|
||||
* align RBAC route prefixes with frontend API paths ([5a7dd3f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a7dd3f16408f3497a9765e79a540ccdabc50e69))
|
||||
* always include details in successful audit log entries ([3dc0b93](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3dc0b93bdfc85fb97f371dc34e024272766afc65))
|
||||
* extract real client IP from X-Forwarded-For/X-Real-IP headers ([af6686c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af6686ccfae12876e867cdabe729d0c893bd85a1))
|
||||
* grant legacy config-based admins full RBAC access ([8893fc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8893fc128e3d8927054f1df1647e896e780c69e7))
|
||||
* improve campaign notifications and ticket media in admin topics ([a594a0f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a594a0f79f48227f75d6102b4586179102c4d344))
|
||||
* RBAC API response format fixes and audit log user info ([4598c27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4598c2785a42773ee8be04ada1c00d14824e07e0))
|
||||
* RBAC audit log action filter and legacy admin level ([c1da8a4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c1da8a4dba5d0c993d3e15b2866bdcfa09de1752))
|
||||
* restore subscription_url and crypto_link after panel sync ([26efb15](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26efb157e476a18b036d09167628a295d7e4c10b))
|
||||
* specify foreign_keys on User.admin_roles_rel to resolve ambiguous join ([bc7d061](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc7d0612f1476f2fdb498cd76a9374b41fd9440a))
|
||||
* stack promo group + promo offer discounts in bot (matching cabinet) ([628997f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/628997fb48413cc4fae9ac491d1c7f6185877200))
|
||||
|
||||
## [3.18.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.1...v3.18.0) (2026-02-24)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add ChatTypeFilterMiddleware to ignore group/forum messages ([25f014f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25f014fd8988b5513fba8fec4483981384687e96))
|
||||
* add multi-channel mandatory subscription system ([8375d7e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8375d7ecc5e54ea935a00175dd26f667eab95346))
|
||||
* add required channels button to admin settings submenu in bot ([3af07ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3af07ff627fc354da4f8c41b0bd0575dddd9afa5))
|
||||
* colored channel subscription buttons via Bot API 9.4 style ([0b3b2e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b3b2e5dc54d8b6b3ede883d5c0f5b91791b7b9b))
|
||||
* rework guide mode with Remnawave API integration ([5a269b2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5a269b249e8e6cad266822095676937481613f5f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing CHANNEL_CHECK_NOT_SUBSCRIBED localization key ([a47ef67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a47ef67090c4e48f466286f7c676eeee0c61a4fb))
|
||||
* address code review issues in guide mode rework ([fae6f71](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fae6f71def421e319733e4edcf1ca80a2831b2ec))
|
||||
* address security review findings ([6feec1e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6feec1eaa847644ba3402763a2ffefd8f770cc01))
|
||||
* callback routing safety and cache invalidation order ([6a50013](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6a50013c21de199df0ba0dab3600b693548b6c1e))
|
||||
* correct broadcast button deep-links for cabinet mode ([e5fa45f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e5fa45f74f969b84f9f1388f8d4888d22c46d7e8))
|
||||
* HTML-escape all externally-sourced text in guide messages ([711ec34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/711ec344c646844401f355695a7e8c0d4fb401ee))
|
||||
* improve deduplication log message wording in monitoring service ([2aead9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2aead9a68b6bf274c8d1497c85f2ed4d4fc9c70b))
|
||||
* invalidate app config cache on local file saves ([978726a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/978726a7856cf56257c49491afe569fa8c395eac))
|
||||
* pre-existing bugs found during review ([1bb939f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bb939f63a360a687fafba26bc363024df0f6be0))
|
||||
* remove [@username](https://github.com/username) channel ID input, auto-prefix -100 for bare digits ([a7db469](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7db469fd7603e7d8dac3076f5d633da654a3a57))
|
||||
* restore RemnaWave config management endpoints ([6f473de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6f473defef32a6d81cee55ef2cd397d536a784a7))
|
||||
* translate required channels handler to Russian, add localization keys ([1bc9074](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1bc9074c1bcdaba7215065c77aac9dd51db4d7c8))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove legacy app-config.json system ([295d2e8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/295d2e877e43f48e9319ba0b01be959904637000))
|
||||
|
||||
## [3.17.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.17.0...v3.17.1) (2026-02-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add diagnostic logging for device_limit sync to RemnaWave ([97b3f89](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97b3f899d12c4bf32b6229a3b595f1b9ad611096))
|
||||
* add int32 overflow guards and strengthen auth validation ([50a931e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/50a931ec363d1842126b90098f93c6cae47a9fac))
|
||||
* add missing broadcast_history columns and harden subscription logic ([d4c4a8a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d4c4a8a211eaf836024f8d9dcb725f25f514f05e))
|
||||
* allow tariff switch when less than 1 day remains ([67f3547](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/67f3547ae2f40153229d71c1abe7e1213466e5c3))
|
||||
* cap expected_monthly_referrals to prevent int32 overflow ([2ef6185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2ef618571570edb6011a365af8aa9cd7e3348c2e))
|
||||
* cross-validate Telegram identity on every authenticated request ([973b3d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/973b3d3d3ff80376c0fd19c531d7aac3ae751df8))
|
||||
* handle RemnaWave API errors in traffic aggregation ([ed4624c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ed4624c6649bdbc04bc850ef63e5c86e26a37ce4))
|
||||
* migrate all remaining naive timestamp columns to timestamptz ([708bb9e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/708bb9eec7ea4360b26709fb2a3f82dd139ed600))
|
||||
* prevent partner self-referral via own campaign link ([115c0c8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/115c0c84c0698591da75d7d3b8fbd8e0fc8541ea))
|
||||
* protect active paid subscriptions from being disabled in RemnaWave ([1b6bbc7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b6bbc7131341b4afd739e4195f02aa956ead616))
|
||||
* repair missing DB columns and make backup resilient to schema mismatches ([c20355b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c20355b06df13328f85cc5a6045b3e490419a30a))
|
||||
* show negative amounts for withdrawals in admin transaction list ([5ee45f9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5ee45f97d179ce2d32b3f19eeb6fd01989a30ca7))
|
||||
* suppress web page preview when logo mode is disabled ([1f4430f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f4430f3af8f3efcc58ef7b562904adcb1640a44))
|
||||
* uploaded backup restore button not triggering handler ([ebe5083](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebe508302b906f8b56cb230b934fb8566990c684))
|
||||
* use aiogram 3.x bot.download() instead of document.download() ([205c8d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/205c8d987d93151a17aa0793cb51bd99917aea97))
|
||||
|
||||
## [3.17.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.3...v3.17.0) (2026-02-18)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add referral code tracking to all cabinet auth methods + email_templates migration ([18c2477](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/18c24771737994f3ae1f832435ed2247ca625aab))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent 'caption is too long' error in logo mode ([6e28a1a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6e28a1a22b02055b357051dfecbee7fefbebc774))
|
||||
* skip blocked users in trial notifications and broadcasts without DB status change ([493f315](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/493f315a65610826a04e04c3d2065e0b395426ed))
|
||||
|
||||
## [3.16.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.2...v3.16.3) (2026-02-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 3 user deletion bugs — type cast, inner savepoint, lazy load ([af31c55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/af31c551d2f23ef01425bdb2db8f255dbc3047e2))
|
||||
* auth middleware catches all commit errors, not just connection errors ([6409b0c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6409b0c023cd7957c43d5c1c3d83e671ccaf959c))
|
||||
* connected_squads stores UUIDs, not int IDs — use get_server_ids_by_uuids ([d7039d7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d7039d75a47fbf67436a9d39f2cd9f65f2646544))
|
||||
* deadlock on user deletion + robust migration 0002 ([b7b83ab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b7b83abb723913b3167e7462ff592a374c3f421b))
|
||||
* eliminate deadlock by matching lock order with webhook ([d651a6c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d651a6c02f501b7a0ded570f2db6addcc16173a9))
|
||||
* make migration 0002 robust with table existence checks ([f076269](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f076269c323726c683a38db092d907591a26e647))
|
||||
* wrap user deletion steps in savepoints to prevent transaction cascade abort ([a38dfcb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a38dfcb75a47a185d979a8202f637d8b79812e67))
|
||||
|
||||
## [3.16.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.1...v3.16.2) (2026-02-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* auto-convert naive datetimes to UTC-aware on model load ([f7d33a7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f7d33a7d2b31145a839ee54676816aa657ac90da))
|
||||
* extend naive datetime guard to all model properties ([bd11801](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bd11801467e917d76005d1a782c71f5ae4ffee6e))
|
||||
* handle naive datetime in raw SQL row comparison (payment/common) ([38f3a9a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/38f3a9a16a24e85adf473f2150aad31574a87060))
|
||||
* handle naive datetimes in Subscription properties ([e512e5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e512e5fe6e9009992b5bc8b9be7f53e0612f234a))
|
||||
* use AwareDateTime TypeDecorator for all datetime columns ([a7f3d65](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a7f3d652c51ecd653900a530b7d38feaf603ecf1))
|
||||
|
||||
## [3.16.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.16.0...v3.16.1) (2026-02-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add migration for partner system tables and columns ([4645be5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4645be53cbb3799aa6b2b6a623af30460357a554))
|
||||
* add migration for partner system tables and columns ([79ea398](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79ea398d1db436a7812a799bf01b2c1c3b1b73be))
|
||||
|
||||
## [3.16.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.1...v3.16.0) (2026-02-18)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add admin notifications for partner applications and withdrawals ([cf7cc5a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf7cc5a84e295608009f255fcd0dcedb5a2a04a3))
|
||||
* add admin partner settings API (withdrawal toggle, requisites text, partner visibility) ([6881d97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6881d97bbb1f6cd8ca3609c2d9286a6e4fb24fc3))
|
||||
* add campaign_id to ReferralEarning for campaign attribution ([0c07812](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0c07812ecc9502f54a7745a77b086fc52bdc0e34))
|
||||
* add partner system and withdrawal management to cabinet ([58bfaea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/58bfaeaddbcbb98cb67dbd507847a0e5c8d07809))
|
||||
* attribute campaign registrations to partner for referral earnings ([767e965](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/767e9650285adc72b067b2c0b8a4d1ac5c5bba57))
|
||||
* blocked user detection during broadcasts, filter blocked from all notifications ([10e231e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10e231e52e0dbabd9195a2df373b3c95129a5e4f))
|
||||
* enforce 1-to-1 partner-campaign binding with partner info in campaigns ([366df18](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/366df18c547047a7c69192c768970ebc6ee426fc))
|
||||
* expose traffic_reset_mode in subscription response ([59383bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/59383bdbd8c72428d151cb24d132452414b14fa3))
|
||||
* expose traffic_reset_mode in tariff API response ([5d4a94b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5d4a94b8cea8f16f0b4c31e24a4695bee4c67af7))
|
||||
* include partner campaigns in /partner/status response ([ea5d932](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ea5d932476553ad1750da3bebbd4b8f055478040))
|
||||
* link campaign registrations to partner for referral earnings ([c4dc43e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4dc43e054e9faec2f9614fe51a64635f80c1796))
|
||||
* notify users on partner/withdrawal approve/reject ([327d4f4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/327d4f4d1559e37dc591adbfd0c839d986d1068d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add blocked_count column migration to universal_migration.py ([b4b10c9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b4b10c998cadbb879540e56dbd0e362b5497ee57))
|
||||
* add missing payment providers to payment_utils and fix {total_amount} formatting ([bdb6161](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bdb61613de378efab4de6de98fde2de3b554c548))
|
||||
* add selectinload for subscription in campaign user list ([eb9dba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb9dba3f4728b478f2206ff992700a9677f879c7))
|
||||
* campaign web link uses ?campaign= param, not ?start= ([28f524b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/28f524b7622ed975d2fece66edc94d9713354738))
|
||||
* correct subscription_service import in broadcast cleanup ([6c4e035](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c4e035146934dffb576477cc75f7365b2f27b99))
|
||||
* critical security and data integrity fixes for partner system ([8899749](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/88997492c3534ea2f6e194c0382c77302557c2f3))
|
||||
* handle YooKassa NotFoundError gracefully in get_payment_info ([df5b1a0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5b1a072d99ff8aee0c94304b2a0214f0fcffe7))
|
||||
* medium-priority fixes for partner system ([7c20fde](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c20fde4e887749d72280a8804467645e5bab416))
|
||||
* move PartnerStatus enum before User class to fix NameError ([acc1323](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/acc1323a542b8e92433cabf1334d2d98bfa21e21))
|
||||
* prevent fileConfig from destroying structlog handlers ([e78b104](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e78b1040a50ac14759bceab396d0c3e34dd79cdd))
|
||||
* reorder button_click_logs migration to nullify before ALTER TYPE ([df5415f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/df5415f30b2aae4412ff5fbd3cac8076128b818c))
|
||||
* resolve HIGH-priority performance and security issues in partner system ([fcf3a2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcf3a2c8062752b2b1dc06b5993ac2d8ae80ee85))
|
||||
* return zeroed stats dict when withdrawal is disabled ([7883efc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7883efc3d6e6d8bedf8e4b7d72634cbab6e2f3d7))
|
||||
* unassign all campaigns when revoking partner status ([d39063b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d39063b22ffb6442e275db39704361cdb9251793))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* replace universal_migration.py with Alembic ([b6c7f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6c7f91a7c79d108820c9f89c9070fde4843316c))
|
||||
* replace universal_migration.py with Alembic ([784616b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/784616b349ef12b35ee021dd7a7b2a2ef9fc57f6))
|
||||
|
||||
## [3.15.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.15.0...v3.15.1) (2026-02-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add naive datetime guards to fromisoformat() in Redis cache readers ([1b3e6f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b3e6f2f11c20aa240da1beb11dd7dfb20dbe6e8))
|
||||
* add naive datetime guards to fromisoformat() in Redis cache readers ([6fa4948](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6fa49485d9f1cd678cb5f9fa7d0375fd47643239))
|
||||
|
||||
## [3.15.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.1...v3.15.0) (2026-02-17)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add LOG_COLORS env setting to toggle console ANSI colors ([27309f5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27309f53d9fa0ba9a2ca07a65feed96bf38f470c))
|
||||
* add web campaign links with bonus processing in auth flow ([d955279](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9552799c17a76e2cc2118699528c5b591bd97fb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* AttributeError in withdrawal admin notification (send_to_admins → send_admin_notification) ([c75ec0b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c75ec0b22a3f674d3e1a24b9d546eca1998701b3))
|
||||
* remove local UTC re-imports shadowing module-level import in purchase.py ([e68760c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e68760cc668016209f4f19a2e08af8680343d6ed))
|
||||
|
||||
## [3.14.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.14.0...v3.14.1) (2026-02-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add naive datetime guards to parsers and fix test datetime literals ([0946090](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/094609005af7358bf5d34d252fc66685bd25751c))
|
||||
* address remaining abs() issues from review ([ff21b27](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff21b27b98bb5a7517e06057eb319c9f3ebb74c7))
|
||||
* complete datetime.utcnow() → datetime.now(UTC) migration ([eb18994](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18994b7d34d777ca39d3278d509e41359e2a85))
|
||||
* normalize transaction amount signs across all aggregations ([4247981](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4247981c98111af388c98628c1e61f0517c57417))
|
||||
* prevent negative amounts in spent display and balance history ([c30972f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30972f6a7911a89a6c3f2080019ff465d11b597))
|
||||
|
||||
## [3.14.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.13.0...v3.14.0) (2026-02-16)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* show all active webhook endpoints in startup log ([9d71005](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d710050ad40ba76a14aa6ace8e8a47f25cdde94))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* force basicConfig to replace pre-existing handlers ([7eb8d4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7eb8d4e153bab640a5829f75bfa6f70df5763284))
|
||||
* NameError in set_user_devices_button — undefined action_text ([1b8ef69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b8ef69a1bbb7d8d86827cf7aaa4f05cbf480d75))
|
||||
* remove unused PaymentService from MonitoringService init ([491a7e1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/491a7e1c425a355e55b3020e2bcc7b96047bdf5e))
|
||||
* resolve MissingGreenlet error when accessing subscription.tariff ([a93a32f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a93a32f3a7d1b259a2e24954ae5d2b7c966c5639))
|
||||
* sync support mode from cabinet admin to SupportSettingsService ([516be6e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/516be6e600a08ad700d83b793dc64b2ca07bdf44))
|
||||
* sync SUPPORT_SYSTEM_MODE between SystemSettings and SupportSettings ([0807a9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0807a9ff19d1eb4f1204f7cbeb1da1c1cfefe83a))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* improve log formatting — logger name prefix and table alignment ([f637204](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f63720467a935bdaaa58bb34d588d65e46698f26))
|
||||
|
||||
## [3.13.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.12.1...v3.13.0) (2026-02-16)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* colored console logs via structlog + rich + FORCE_COLOR ([bf64611](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf646112df02aa7aa7918d0513cb6968ceb7f378))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* limit Rich traceback output to prevent console flood ([11ef714](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11ef714e0dde25a08711c0daeee943b6e71e20b7))
|
||||
* resolve exc_info for admin notifications, clean log formatting ([11f8af0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11f8af003fc60384abafa2b670b89d6ad3ac57a4))
|
||||
* suppress startup log noise (~350 lines → ~30) ([8a6650e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a6650e57cd8ea396d9b057a7753469947f38d29))
|
||||
* traceback in Telegram notifications + reduce log padding ([909a403](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/909a4039c43b910761bd05c36e79c8e6773199db))
|
||||
* use sync context manager for structlog bound_contextvars ([25e8c9f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/25e8c9f8fc4d2c66d5a1407d3de5c7402dc596da))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* complete structlog migration with contextvars, kwargs, and logging hardening ([1f0fef1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1f0fef114bd979b2b0d2bd38dde6ce05e7bba07b))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.12.0" # x-release-please-version
|
||||
ARG VERSION="v3.20.1" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -40,6 +40,22 @@ fix: ## Исправить код (ruff check --fix + format)
|
||||
uv run ruff check . --fix
|
||||
uv run ruff format .
|
||||
|
||||
.PHONY: migrate
|
||||
migrate: ## Применить миграции (alembic upgrade head)
|
||||
uv run alembic upgrade head
|
||||
|
||||
.PHONY: migration
|
||||
migration: ## Создать миграцию (usage: make migration m="description")
|
||||
uv run alembic revision --autogenerate -m "$(m)"
|
||||
|
||||
.PHONY: migrate-stamp
|
||||
migrate-stamp: ## Пометить БД как актуальную (для существующих БД)
|
||||
uv run alembic stamp head
|
||||
|
||||
.PHONY: migrate-history
|
||||
migrate-history: ## Показать историю миграций
|
||||
uv run alembic history --verbose
|
||||
|
||||
.PHONY: help
|
||||
help: ## Показать список доступных команд
|
||||
@echo ""
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
script_location = migrations/alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+asyncpg://vpn_user:your_password@localhost:5432/vpn_bot
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
|
||||
-658
@@ -1,658 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"additionalLocales": [
|
||||
"ru",
|
||||
"zh",
|
||||
"fa"
|
||||
],
|
||||
"branding": {
|
||||
"name": "Subscription",
|
||||
"logoUrl": "https://raw.githubusercontent.com/Fr1ngg/remnawave-bedolaga-telegram-bot/bf0c1ce711a26fa2f24559e7e4443820e68d758b/assets/bedolaga_app3.svg",
|
||||
"supportUrl": "https://t.me"
|
||||
}
|
||||
},
|
||||
"platforms": {
|
||||
"ios": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/us/app/happ-proxy-utility/id6504287215",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store [EU]",
|
||||
"fa": "باز کردن در App Store [EU]",
|
||||
"ru": "Открыть в App Store [EU]",
|
||||
"zh": "在 App Store 中打开 [EU]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store [RU]",
|
||||
"fa": "باز کردن در App Store [RU]",
|
||||
"ru": "Открыть в App Store [RU]",
|
||||
"zh": "在 App Store 中打开 [RU]"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "streisand",
|
||||
"name": "Streisand",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "streisand://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/streisand/id6450534064",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "shadowrocket",
|
||||
"name": "Shadowrocket",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "sub://",
|
||||
"isNeedBase64Encoding": true,
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://apps.apple.com/ru/app/shadowrocket/id932747118",
|
||||
"buttonText": {
|
||||
"en": "Open in App Store",
|
||||
"fa": "باز کردن در App Store",
|
||||
"ru": "Открыть в App Store",
|
||||
"zh": "在 App Store 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in App Store and install the app. Launch it, in the VPN configuration permission window click Allow and enter your passcode.",
|
||||
"fa": "صفحه را در App Store باز کنید و برنامه را نصب کنید. آن را اجرا کنید، در پنجره مجوز پیکربندی VPN روی Allow کلیک کنید و رمز عبور خود را وارد کنید.",
|
||||
"ru": "Откройте страницу в App Store и установите приложение. Запустите его, в окне разрешения VPN-конфигурации нажмите Allow и введите свой пароль.",
|
||||
"zh": "在 App Store 中打开页面并安装应用。启动应用后,在 VPN 配置权限窗口中点击\"允许\"并输入您的密码。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below — the app will open and the subscription will be added automatically",
|
||||
"fa": "برای افزودن خودکار اشتراک روی دکمه زیر کلیک کنید - برنامه باز خواهد شد",
|
||||
"ru": "Нажмите кнопку ниже — приложение откроется, и подписка добавится автоматически.",
|
||||
"zh": "点击下方按钮 — 应用将打开并自动添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, choose another server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"android": [
|
||||
{
|
||||
"id": "happ",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.happproxy",
|
||||
"buttonText": {
|
||||
"en": "Open in Google Play",
|
||||
"fa": "باز کردن در Google Play",
|
||||
"ru": "Открыть в Google Play",
|
||||
"zh": "在 Google Play 中打开"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"fa": "صفحه را در Google Play باز کنید و برنامه را نصب کنید. یا برنامه را مستقیماً از فایل APK نصب کنید، اگر Google Play کار نمی کند.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение. Или установите приложение из APK файла напрямую, если Google Play не работает.",
|
||||
"zh": "在 Google Play 中打开页面并安装应用。如果 Google Play 无法使用,也可以直接从 APK 文件安装应用。"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"fa": "برنامه را باز کنید و به سرور متصل شوید",
|
||||
"ru": "Откройте приложение и подключитесь к серверу",
|
||||
"zh": "打开应用并连接到服务器"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "clash-meta",
|
||||
"name": "Clash Meta",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/MetaCubeX/ClashMetaForAndroid/releases/download/v2.11.7/cmfa-2.11.7-meta-universal-release.apk",
|
||||
"buttonText": {
|
||||
"en": "Download APK",
|
||||
"fa": "دانلود APK",
|
||||
"ru": "Скачать APK",
|
||||
"zh": "下载 APK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://f-droid.org/packages/com.github.metacubex.clash.meta/",
|
||||
"buttonText": {
|
||||
"en": "Open in F-Droid",
|
||||
"fa": "در F-Droid باز کنید",
|
||||
"ru": "Открыть в F-Droid",
|
||||
"zh": "在 F-Droid 中打开"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Download and install Clash Meta APK",
|
||||
"fa": "دانلود و نصب Clash Meta APK",
|
||||
"ru": "Скачайте и установите Clash Meta APK",
|
||||
"zh": "下载并安装 Clash Meta APK"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Tap the button to import configuration",
|
||||
"fa": "برای وارد کردن پیکربندی روی دکمه ضربه بزنید",
|
||||
"ru": "Нажмите кнопку, чтобы импортировать конфигурацию",
|
||||
"zh": "点击按钮导入配置"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open Clash Meta and tap on Connect",
|
||||
"fa": "Clash Meta را باز کنید و روی اتصال ضربه بزنید",
|
||||
"ru": "Откройте Clash Meta и нажмите Подключиться",
|
||||
"zh": "打开 Clash Meta 并点击连接"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"macos": [
|
||||
{
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выбрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"windows": [
|
||||
{
|
||||
"id": "clash-verge",
|
||||
"name": "Clash Verge",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "clash://install-config?url=",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64-setup.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_x64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Intel)",
|
||||
"fa": "مک (اینتل)",
|
||||
"ru": "macOS (Intel)",
|
||||
"zh": "macOS (Intel)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/v2.2.2/Clash.Verge_2.2.2_aarch64.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS (Apple Silicon)",
|
||||
"fa": "مک (Apple Silicon)",
|
||||
"ru": "macOS (Apple Silicon)",
|
||||
"zh": "macOS (Apple Silicon)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/clash-verge-rev/clash-verge-rev/releases",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Choose the version for your device, click the button below and install the app.",
|
||||
"fa": "نسخه مناسب برای دستگاه خود را انتخاب کنید، دکمه زیر را فشار دهید و برنامه را نصب کنید",
|
||||
"ru": "Выберите подходящую версию для вашего устройства, нажмите на кнопку ниже и установите приложение.",
|
||||
"zh": "选择适合您设备的版本,点击下方按钮并安装应用。"
|
||||
}
|
||||
},
|
||||
"additionalBeforeAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"description": {
|
||||
"en": "After launching the app, you can change the language in settings. In the left panel, find the gear icon, then navigate to Verge 设置 and select 语言设置.",
|
||||
"fa": "پس از راهاندازی برنامه، میتوانید زبان را در تنظیمات تغییر دهید. در پنل سمت چپ، نماد چرخ دنده را پیدا کنید، سپس به Verge 设置 بروید و 语言设置 را انتخاب کنید.",
|
||||
"ru": "После запуска приложения вы можете сменить язык в настройках. В левой панели найдите иконку шестеренки, далее ориентируйтесь на Verge 设置 и выберите пункт 语言设置.",
|
||||
"zh": "启动应用后,您可以在设置中更改语言。在左侧面板找到齿轮图标,然后导航到 Verge 设置并选择语言设置。"
|
||||
},
|
||||
"title": {
|
||||
"en": "Change language",
|
||||
"fa": "تغییر زبان",
|
||||
"ru": "Смена языка",
|
||||
"zh": "更改语言"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"additionalAfterAddSubscriptionStep": {
|
||||
"buttons": [],
|
||||
"title": {
|
||||
"en": "If the subscription is not added",
|
||||
"fa": "اگر اشتراک در برنامه نصب نشده است",
|
||||
"ru": "Если подписка не добавилась",
|
||||
"zh": "如果订阅未添加"
|
||||
},
|
||||
"description": {
|
||||
"en": "If nothing happens after clicking the button, add the subscription manually. Click the Get Link button in the top right corner of this page, copy the link. In Clash Verge, go to the Profiles section and paste the link in the text field, then click the Import button.",
|
||||
"fa": "اگر پس از کلیک روی دکمه اتفاقی نیفتاد، اشتراک را به صورت دستی اضافه کنید. در گوشه بالا سمت راست این صفحه روی دکمه دریافت لینک کلیک کنید، لینک را کپی کنید. در Clash Verge به بخش پروفایلها بروید و لینک را در فیلد متنی وارد کنید، سپس روی دکمه وارد کردن کلیک کنید.",
|
||||
"ru": "Если после нажатия на кнопку ничего не произошло, добавьте подписку вручную. Нажмите на этой страницу кнопку Получить ссылку в правом верхнем углу, скопируйте ссылку. В Clash Verge перейдите в раздел Профили и вставьте ссылку в текстовое поле, затем нажмите на кнопку Импорт.",
|
||||
"zh": "如果点击按钮后没有反应,请手动添加订阅。点击此页面右上角的获取链接按钮,复制链接。在 Clash Verge 中,转到配置文件部分,将链接粘贴到文本字段中,然后点击导入按钮。"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "You can select a server in the Proxy section, and enable VPN in the Settings section. Set the TUN Mode switch to ON.",
|
||||
"fa": "میتوانید در بخش پروکسی سرور را انتخاب کنید و در بخش تنظیمات VPN را فعال کنید. کلید TUN Mode را در حالت روشن قرار دهید.",
|
||||
"ru": "Выبрать сервер можно в разделе Прокси, включить VPN можно в разделе Настройки. Установите переключатель TUN Mode в положение ВКЛ.",
|
||||
"zh": "您可以在代理部分选择服务器,在设置部分启用 VPN。将 TUN 模式开关设置为开启。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "hiddify",
|
||||
"name": "Hiddify",
|
||||
"isFeatured": false,
|
||||
"urlScheme": "hiddify://import/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Windows-Setup-x64.exe",
|
||||
"buttonText": {
|
||||
"en": "Windows",
|
||||
"fa": "ویندوز",
|
||||
"ru": "Windows",
|
||||
"zh": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-MacOS.dmg",
|
||||
"buttonText": {
|
||||
"en": "macOS",
|
||||
"fa": "مک",
|
||||
"ru": "macOS",
|
||||
"zh": "macOS"
|
||||
}
|
||||
},
|
||||
{
|
||||
"buttonLink": "https://github.com/hiddify/hiddify-app/releases/download/v2.5.7/Hiddify-Linux-x64.AppImage",
|
||||
"buttonText": {
|
||||
"en": "Linux",
|
||||
"fa": "لینوکس",
|
||||
"ru": "Linux",
|
||||
"zh": "Linux"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. If needed, select a different server in the Proxy section",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. در صورت نیاز، سرور دیگری را در بخش پروکسی انتخاب کنید",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. При необходимости выберите другой сервер в разделе Прокси.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接 VPN。如有需要,可在代理部分选择不同的服务器"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"fa": "برای افزودن اشتراک روی دکمه زیر کلیک کنید",
|
||||
"ru": "Нажмите кнопку ниже, чтобы добавить подписку",
|
||||
"zh": "点击下方按钮添加订阅"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "In the main section, click the large power button in the center to connect to VPN. Don't forget to select a server from the server list. If needed, select a different server from the server list.",
|
||||
"fa": "در بخش اصلی، دکمه بزرگ روشن/خاموش در مرکز را برای اتصال به VPN کلیک کنید. فراموش نکنید که یک سرور را از لیست سرورها انتخاب کنید. در صورت نیاز، سرور دیگری را از لیست سرورها انتخاب کنید.",
|
||||
"ru": "В главном разделе нажмите большую кнопку включения в центре для подключения к VPN. Не забудьте выбрать сервер в списке серверов. При необходимости выберите другой сервер из списка серверов.",
|
||||
"zh": "在主界面中,点击中央的大电源按钮连接到 VPN。别忘了从服务器列表中选择一个服务器。如有需要,可从服务器列表中选择其他服务器。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"linux": [],
|
||||
"androidTV": [
|
||||
{
|
||||
"id": "new-app-androidtv-1760203310792",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Button TextGoogle Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"appleTV": [
|
||||
{
|
||||
"id": "new-app-appletv-1760203488851",
|
||||
"name": "Happ",
|
||||
"isFeatured": true,
|
||||
"urlScheme": "happ://add/",
|
||||
"installationStep": {
|
||||
"buttons": [
|
||||
{
|
||||
"buttonLink": "https://play.google.com/store/apps/details?id=com.vpn4tv.hiddify",
|
||||
"buttonText": {
|
||||
"en": "Google Play",
|
||||
"ru": "Google Play",
|
||||
"zh": "Button Text",
|
||||
"fa": "Button Text"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": {
|
||||
"en": "Open the page in Google Play and install the app. Or install the app directly from the APK file if Google Play is not working.",
|
||||
"ru": "Откройте страницу в Google Play и установите приложение",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"addSubscriptionStep": {
|
||||
"description": {
|
||||
"en": "Click the button below to add subscription",
|
||||
"ru": "Нажмите кнопку выше — (Скопировать ссылку подписки) ты скопируешь свою подписку, далее на телевизоре открой VPN4TV, следуя инструкция передай telegram боту ссылку, которую ты скопировал",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
},
|
||||
"connectAndUseStep": {
|
||||
"description": {
|
||||
"en": "Open the app and connect to the server",
|
||||
"ru": "Приложение автоматически обновится и загрузит нужные конфиги на твой телевизор, подключай VPN",
|
||||
"zh": "-",
|
||||
"fa": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+28
-21
@@ -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
|
||||
@@ -46,6 +45,7 @@ from app.handlers.admin import (
|
||||
referrals as admin_referrals,
|
||||
remnawave as admin_remnawave,
|
||||
reports as admin_reports,
|
||||
required_channels as admin_required_channels,
|
||||
rules as admin_rules,
|
||||
servers as admin_servers,
|
||||
statistics as admin_statistics,
|
||||
@@ -59,10 +59,13 @@ from app.handlers.admin import (
|
||||
users as admin_users,
|
||||
welcome_text as admin_welcome_text,
|
||||
)
|
||||
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
|
||||
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.chat_type_filter import ChatTypeFilterMiddleware
|
||||
from app.middlewares.context_binding import ContextVarsMiddleware
|
||||
from app.middlewares.global_error import GlobalErrorMiddleware
|
||||
from app.middlewares.logging import LoggingMiddleware
|
||||
from app.middlewares.maintenance import MaintenanceMiddleware
|
||||
@@ -75,14 +78,14 @@ from app.utils.message_patch import patch_message_methods
|
||||
|
||||
patch_message_methods()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def debug_callback_handler(callback: types.CallbackQuery):
|
||||
logger.info('🔍 DEBUG CALLBACK:')
|
||||
logger.info(f' - Data: {callback.data}')
|
||||
logger.info(f' - User: {callback.from_user.id}')
|
||||
logger.info(f' - Username: {callback.from_user.username}')
|
||||
logger.info('Data', callback_data=callback.data)
|
||||
logger.info('User', from_user_id=callback.from_user.id)
|
||||
logger.info('Username', username=callback.from_user.username)
|
||||
|
||||
|
||||
async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
@@ -90,7 +93,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
await cache.connect()
|
||||
logger.info('Кеш инициализирован')
|
||||
except Exception as e:
|
||||
logger.warning(f'Кеш не инициализирован: {e}')
|
||||
logger.warning('Кеш не инициализирован', error=e)
|
||||
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -106,12 +109,18 @@ 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())
|
||||
chat_type_filter = ChatTypeFilterMiddleware()
|
||||
dp.message.middleware(chat_type_filter)
|
||||
dp.callback_query.middleware(chat_type_filter)
|
||||
dp.message.middleware(GlobalErrorMiddleware())
|
||||
dp.callback_query.middleware(GlobalErrorMiddleware())
|
||||
dp.pre_checkout_query.middleware(GlobalErrorMiddleware())
|
||||
@@ -132,15 +141,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
dp.callback_query.middleware(button_stats_middleware)
|
||||
logger.info('📊 ButtonStatsMiddleware активирован')
|
||||
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
from app.middlewares.channel_checker import ChannelCheckerMiddleware
|
||||
|
||||
channel_checker_middleware = ChannelCheckerMiddleware()
|
||||
dp.message.middleware(channel_checker_middleware)
|
||||
dp.callback_query.middleware(channel_checker_middleware)
|
||||
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
|
||||
else:
|
||||
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
|
||||
channel_checker = ChannelCheckerMiddleware()
|
||||
dp.message.middleware(channel_checker)
|
||||
dp.callback_query.middleware(channel_checker)
|
||||
dp.message.middleware(AuthMiddleware())
|
||||
dp.callback_query.middleware(AuthMiddleware())
|
||||
dp.pre_checkout_query.middleware(AuthMiddleware())
|
||||
@@ -191,6 +196,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_bulk_ban.register_bulk_ban_handlers(dp)
|
||||
admin_blacklist.register_blacklist_handlers(dp)
|
||||
admin_blocked_users.register_handlers(dp)
|
||||
admin_required_channels.register_handlers(dp)
|
||||
register_channel_member_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
user_contests.register_handlers(dp)
|
||||
@@ -205,7 +212,7 @@ 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('Мониторинг техработ отключен настройками')
|
||||
|
||||
@@ -236,7 +243,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
'Установите MINIAPP_CUSTOM_URL.'
|
||||
)
|
||||
elif settings.is_cabinet_mode():
|
||||
logger.info(f'🏠 Режим Cabinet активен, базовый URL: {settings.MINIAPP_CUSTOM_URL}')
|
||||
logger.info('🏠 Режим Cabinet активен, базовый URL', MINIAPP_CUSTOM_URL=settings.MINIAPP_CUSTOM_URL)
|
||||
|
||||
# Load per-section button styles cache
|
||||
if settings.is_cabinet_mode():
|
||||
@@ -245,7 +252,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
|
||||
await load_button_styles_cache()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to load button styles cache: {e}')
|
||||
logger.warning('Failed to load button styles cache', error=e)
|
||||
|
||||
logger.info('Бот успешно настроен')
|
||||
|
||||
@@ -257,10 +264,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,7 +1,7 @@
|
||||
"""Email verification token generation and validation."""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.config import settings
|
||||
|
||||
@@ -24,7 +24,7 @@ def get_email_change_expires_at() -> datetime:
|
||||
Datetime when the email change code expires
|
||||
"""
|
||||
minutes = settings.get_cabinet_email_change_code_expire_minutes()
|
||||
return datetime.utcnow() + timedelta(minutes=minutes)
|
||||
return datetime.now(UTC) + timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def generate_verification_token() -> str:
|
||||
@@ -55,7 +55,7 @@ def get_verification_expires_at() -> datetime:
|
||||
Datetime when the verification token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_email_verification_expire_hours()
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
return datetime.now(UTC) + timedelta(hours=hours)
|
||||
|
||||
|
||||
def get_password_reset_expires_at() -> datetime:
|
||||
@@ -66,7 +66,7 @@ def get_password_reset_expires_at() -> datetime:
|
||||
Datetime when the password reset token expires
|
||||
"""
|
||||
hours = settings.get_cabinet_password_reset_expire_hours()
|
||||
return datetime.utcnow() + timedelta(hours=hours)
|
||||
return datetime.now(UTC) + timedelta(hours=hours)
|
||||
|
||||
|
||||
def is_token_expired(expires_at: datetime | None) -> bool:
|
||||
@@ -81,4 +81,4 @@ def is_token_expired(expires_at: datetime | None) -> bool:
|
||||
"""
|
||||
if expires_at is None:
|
||||
return True
|
||||
return datetime.utcnow() > expires_at
|
||||
return datetime.now(UTC) > expires_at
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""JWT token handling for cabinet authentication."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
@@ -11,31 +11,49 @@ from app.config import settings
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
|
||||
|
||||
def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
|
||||
def create_access_token(
|
||||
user_id: int,
|
||||
telegram_id: int | None = None,
|
||||
*,
|
||||
permissions: list[str] | None = None,
|
||||
roles: list[str] | None = None,
|
||||
role_level: int = 0,
|
||||
) -> str:
|
||||
"""
|
||||
Create a short-lived access token.
|
||||
|
||||
Args:
|
||||
user_id: Database user ID
|
||||
telegram_id: Telegram user ID (optional for email-only users)
|
||||
permissions: RBAC permission strings to embed in token
|
||||
roles: Role names to embed in token
|
||||
role_level: Maximum role level (0 = no special level)
|
||||
|
||||
Returns:
|
||||
Encoded JWT access token
|
||||
"""
|
||||
expire_minutes = settings.get_cabinet_access_token_expire_minutes()
|
||||
expires = datetime.utcnow() + timedelta(minutes=expire_minutes)
|
||||
expires = datetime.now(UTC) + timedelta(minutes=expire_minutes)
|
||||
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'access',
|
||||
'exp': expires,
|
||||
'iat': datetime.utcnow(),
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
|
||||
# Добавляем telegram_id только если он есть
|
||||
if telegram_id is not None:
|
||||
payload['telegram_id'] = telegram_id
|
||||
|
||||
# RBAC data — only include when provided to keep token compact
|
||||
if permissions is not None:
|
||||
payload['permissions'] = permissions
|
||||
if roles is not None:
|
||||
payload['roles'] = roles
|
||||
if role_level > 0:
|
||||
payload['role_level'] = role_level
|
||||
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM)
|
||||
|
||||
@@ -51,13 +69,13 @@ def create_refresh_token(user_id: int) -> str:
|
||||
Encoded JWT refresh token
|
||||
"""
|
||||
expire_days = settings.get_cabinet_refresh_token_expire_days()
|
||||
expires = datetime.utcnow() + timedelta(days=expire_days)
|
||||
expires = datetime.now(UTC) + timedelta(days=expire_days)
|
||||
|
||||
payload = {
|
||||
'sub': str(user_id),
|
||||
'type': 'refresh',
|
||||
'exp': expires,
|
||||
'iat': datetime.utcnow(),
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
|
||||
secret = settings.get_cabinet_jwt_secret()
|
||||
@@ -108,4 +126,4 @@ def get_token_payload(token: str, expected_type: str = 'access') -> dict[str, An
|
||||
def get_refresh_token_expires_at() -> datetime:
|
||||
"""Get the expiration datetime for a new refresh token."""
|
||||
expire_days = settings.get_cabinet_refresh_token_expire_days()
|
||||
return datetime.utcnow() + timedelta(days=expire_days)
|
||||
return datetime.now(UTC) + timedelta(days=expire_days)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl, unquote
|
||||
|
||||
@@ -34,8 +34,8 @@ def validate_telegram_login_widget(data: dict[str, Any], max_age_seconds: int =
|
||||
if auth_date:
|
||||
try:
|
||||
# Use UTC timestamp to avoid timezone issues
|
||||
auth_time = datetime.utcfromtimestamp(int(auth_date))
|
||||
age = (datetime.utcnow() - auth_time).total_seconds()
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds:
|
||||
return False
|
||||
except (ValueError, TypeError, OSError):
|
||||
@@ -81,8 +81,8 @@ def validate_telegram_init_data(init_data: str, max_age_seconds: int = 86400) ->
|
||||
if auth_date:
|
||||
try:
|
||||
# Use UTC timestamp to avoid timezone issues
|
||||
auth_time = datetime.utcfromtimestamp(int(auth_date))
|
||||
age = (datetime.utcnow() - auth_time).total_seconds()
|
||||
auth_time = datetime.fromtimestamp(int(auth_date), tz=UTC)
|
||||
age = (datetime.now(UTC) - auth_time).total_seconds()
|
||||
if age > max_age_seconds:
|
||||
return None
|
||||
except (ValueError, TypeError, OSError):
|
||||
|
||||
+192
-54
@@ -1,10 +1,7 @@
|
||||
"""FastAPI dependencies for cabinet module."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiogram import Bot
|
||||
from fastapi import Depends, HTTPException, status
|
||||
import structlog
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -16,23 +13,13 @@ from app.services.blacklist_service import blacklist_service
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
|
||||
from .auth.jwt_handler import get_token_payload
|
||||
from .auth.telegram_auth import validate_telegram_init_data
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
# Кешированный Bot для проверки подписки на канал
|
||||
_channel_check_bot: Bot | None = None
|
||||
|
||||
|
||||
def _get_channel_check_bot() -> Bot:
|
||||
"""Получить или создать Bot для проверки подписки на канал."""
|
||||
global _channel_check_bot
|
||||
if _channel_check_bot is None:
|
||||
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
|
||||
return _channel_check_bot
|
||||
|
||||
|
||||
async def get_cabinet_db() -> AsyncSession:
|
||||
"""Get database session for cabinet operations."""
|
||||
@@ -44,6 +31,7 @@ async def get_cabinet_db() -> AsyncSession:
|
||||
|
||||
|
||||
async def get_current_cabinet_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User:
|
||||
@@ -51,6 +39,7 @@ async def get_current_cabinet_user(
|
||||
Get current authenticated cabinet user from JWT token.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object (for reading X-Telegram-Init-Data header)
|
||||
credentials: HTTP Bearer credentials
|
||||
db: Database session
|
||||
|
||||
@@ -105,6 +94,34 @@ async def get_current_cabinet_user(
|
||||
detail='User account is not active',
|
||||
)
|
||||
|
||||
# Defense in depth: cross-validate Telegram identity.
|
||||
# The frontend sends X-Telegram-Init-Data on every request.
|
||||
# If the header is present and cryptographically valid, verify that
|
||||
# the Telegram user ID matches the JWT user's telegram_id.
|
||||
# This prevents cross-account token reuse when Telegram WebView
|
||||
# shares localStorage across accounts on the same device.
|
||||
init_data_raw = request.headers.get('X-Telegram-Init-Data')
|
||||
if init_data_raw and user.telegram_id is not None:
|
||||
# Use generous max_age: Telegram Desktop caches initData
|
||||
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
|
||||
if tg_user is None:
|
||||
logger.warning(
|
||||
'Telegram initData validation failed but header was present',
|
||||
jwt_user_id=user.id,
|
||||
)
|
||||
elif tg_user.get('id') != user.telegram_id:
|
||||
logger.warning(
|
||||
'Telegram identity mismatch: JWT belongs to different user than current Telegram account',
|
||||
jwt_user_id=user.id,
|
||||
jwt_telegram_id=user.telegram_id,
|
||||
init_data_telegram_id=tg_user.get('id'),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Session belongs to a different Telegram account. Please restart the app.',
|
||||
headers={'WWW-Authenticate': 'Bearer'},
|
||||
)
|
||||
|
||||
# Check blacklist
|
||||
if user.telegram_id is not None:
|
||||
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
|
||||
@@ -132,45 +149,37 @@ async def get_current_cabinet_user(
|
||||
},
|
||||
)
|
||||
|
||||
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
|
||||
# Пропускаем проверку для email-only юзеров (нет telegram_id)
|
||||
# Check required channel subscription - Telegram users only
|
||||
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
# Skip for email-only users (no telegram_id)
|
||||
if user.telegram_id is not None:
|
||||
# Проверяем админа по telegram_id ИЛИ email
|
||||
# Skip admin check
|
||||
is_admin = settings.is_admin(
|
||||
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
|
||||
)
|
||||
if not is_admin:
|
||||
try:
|
||||
bot = _get_channel_check_bot()
|
||||
chat_member = await asyncio.wait_for(
|
||||
bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=user.telegram_id),
|
||||
timeout=10.0,
|
||||
)
|
||||
# Не закрываем сессию - бот переиспользуется
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
|
||||
if chat_member.status not in ['member', 'administrator', 'creator']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'channel_subscription_required',
|
||||
'message': 'Please subscribe to our channel to continue',
|
||||
'channel_link': settings.CHANNEL_LINK,
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except TimeoutError:
|
||||
logger.warning(f'Timeout checking channel subscription for user {user.telegram_id}')
|
||||
# Don't block user if check times out
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to check channel subscription for user {user.telegram_id}: {e}')
|
||||
# Don't block user if check fails
|
||||
channels_with_status = await channel_subscription_service.get_channels_with_status(user.telegram_id)
|
||||
is_subscribed = (
|
||||
all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
|
||||
)
|
||||
|
||||
if not is_subscribed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
'code': 'channel_subscription_required',
|
||||
'message': 'Please subscribe to the required channels to continue',
|
||||
'channels': channels_with_status,
|
||||
},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_cabinet_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User | None:
|
||||
@@ -198,31 +207,160 @@ async def get_optional_cabinet_user(
|
||||
if not user or user.status != 'active':
|
||||
return None
|
||||
|
||||
# Cross-validate Telegram identity (same as get_current_cabinet_user)
|
||||
init_data_raw = request.headers.get('X-Telegram-Init-Data')
|
||||
if init_data_raw and user.telegram_id is not None:
|
||||
tg_user = validate_telegram_init_data(init_data_raw, max_age_seconds=86400 * 30)
|
||||
if tg_user and tg_user.get('id') != user.telegram_id:
|
||||
logger.warning(
|
||||
'Telegram identity mismatch in optional auth',
|
||||
jwt_user_id=user.id,
|
||||
jwt_telegram_id=user.telegram_id,
|
||||
init_data_telegram_id=tg_user.get('id'),
|
||||
)
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User:
|
||||
"""
|
||||
Get current authenticated admin user.
|
||||
|
||||
Checks if the user is admin by telegram_id or email.
|
||||
Checks if the user is admin by legacy config (ADMIN_IDS / ADMIN_EMAILS)
|
||||
**or** by RBAC role assignment (any role with level > 0).
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
user: Authenticated User object
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Authenticated admin User object
|
||||
|
||||
Raises:
|
||||
HTTPException: If user is not an admin
|
||||
HTTPException: If user is not an admin by either mechanism
|
||||
"""
|
||||
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
|
||||
if not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Admin access required',
|
||||
)
|
||||
# Legacy check: config-based admin list
|
||||
is_legacy_admin = settings.is_admin(
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email if user.email_verified else None,
|
||||
)
|
||||
if is_legacy_admin:
|
||||
return user
|
||||
|
||||
return user
|
||||
# RBAC check: user has any active role with level > 0
|
||||
from app.database.crud.rbac import UserRoleCRUD
|
||||
|
||||
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
||||
if max_level > 0:
|
||||
return user
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Admin access required',
|
||||
)
|
||||
|
||||
|
||||
def require_permission(*permissions: str):
|
||||
"""
|
||||
FastAPI dependency factory for RBAC permission checks.
|
||||
|
||||
Usage::
|
||||
|
||||
@router.get("/users", dependencies=[Depends(require_permission("users:read"))])
|
||||
async def list_users(...): ...
|
||||
|
||||
# Or inject the user:
|
||||
@router.get("/users")
|
||||
async def list_users(user: User = Depends(require_permission("users:read"))): ...
|
||||
"""
|
||||
if not permissions:
|
||||
raise ValueError('require_permission() requires at least one permission argument')
|
||||
|
||||
async def dependency(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User:
|
||||
from app.services.permission_service import PermissionService
|
||||
|
||||
ip_address = (
|
||||
request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.headers.get('X-Real-IP', '').strip()
|
||||
or (request.client.host if request.client else None)
|
||||
)
|
||||
user_agent = request.headers.get('user-agent', '')
|
||||
|
||||
# Extract resource_type from the first permission (section before ':')
|
||||
resource_type = None
|
||||
if permissions:
|
||||
first_perm = permissions[0]
|
||||
if ':' in first_perm:
|
||||
resource_type = first_perm.split(':', maxsplit=1)[0]
|
||||
|
||||
for perm in permissions:
|
||||
allowed, reason = await PermissionService.check_permission(
|
||||
db,
|
||||
user,
|
||||
perm,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
if not allowed:
|
||||
await PermissionService.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
action=perm,
|
||||
resource_type=resource_type,
|
||||
status='denied',
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_method=request.method,
|
||||
request_path=str(request.url.path),
|
||||
details={'reason': reason},
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'Permission denied: {reason}',
|
||||
)
|
||||
|
||||
# Capture request details
|
||||
details: dict = {
|
||||
'method': request.method,
|
||||
'path': str(request.url.path),
|
||||
}
|
||||
query_params = dict(request.query_params)
|
||||
if query_params:
|
||||
details['query_params'] = query_params
|
||||
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
|
||||
try:
|
||||
body = await request.body()
|
||||
if body:
|
||||
import json
|
||||
|
||||
details['request_body'] = json.loads(body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Log successful access with all requested permissions
|
||||
await PermissionService.log_action(
|
||||
db,
|
||||
user_id=user.id,
|
||||
action=','.join(permissions),
|
||||
resource_type=resource_type,
|
||||
status='success',
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_method=request.method,
|
||||
request_path=str(request.url.path),
|
||||
details=details,
|
||||
)
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
return dependency
|
||||
|
||||
@@ -3,17 +3,22 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .admin_apps import router as admin_apps_router
|
||||
from .admin_audit_log import router as admin_audit_log_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_channels import router as admin_channels_router
|
||||
from .admin_email_templates import router as admin_email_templates_router
|
||||
from .admin_partners import router as admin_partners_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_policies import router as admin_policies_router
|
||||
from .admin_promo_offers import router as admin_promo_offers_router
|
||||
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
|
||||
from .admin_remnawave import router as admin_remnawave_router
|
||||
from .admin_roles import router as admin_roles_router
|
||||
from .admin_servers import router as admin_servers_router
|
||||
from .admin_settings import router as admin_settings_router
|
||||
from .admin_stats import router as admin_stats_router
|
||||
@@ -23,6 +28,7 @@ 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 .admin_withdrawals import router as admin_withdrawals_router
|
||||
from .auth import router as auth_router
|
||||
from .balance import router as balance_router
|
||||
from .branding import router as branding_router
|
||||
@@ -31,6 +37,7 @@ from .info import router as info_router
|
||||
from .media import router as media_router
|
||||
from .notifications import router as notifications_router
|
||||
from .oauth import router as oauth_router
|
||||
from .partner_application import router as partner_application_router
|
||||
from .polls import router as polls_router
|
||||
from .promo import router as promo_router
|
||||
from .promocode import router as promocode_router
|
||||
@@ -43,6 +50,7 @@ from .ticket_notifications import (
|
||||
from .tickets import router as tickets_router
|
||||
from .websocket import router as websocket_router
|
||||
from .wheel import router as wheel_router
|
||||
from .withdrawal import router as withdrawal_router
|
||||
|
||||
|
||||
# Main cabinet router
|
||||
@@ -54,6 +62,8 @@ router.include_router(oauth_router)
|
||||
router.include_router(subscription_router)
|
||||
router.include_router(balance_router)
|
||||
router.include_router(referral_router)
|
||||
router.include_router(partner_application_router)
|
||||
router.include_router(withdrawal_router)
|
||||
# Notifications router MUST be before tickets router to avoid route conflict
|
||||
router.include_router(ticket_notifications_router)
|
||||
router.include_router(tickets_router)
|
||||
@@ -73,7 +83,6 @@ router.include_router(wheel_router)
|
||||
router.include_router(admin_ticket_notifications_router)
|
||||
router.include_router(admin_tickets_router)
|
||||
router.include_router(admin_settings_router)
|
||||
router.include_router(admin_apps_router)
|
||||
router.include_router(admin_wheel_router)
|
||||
router.include_router(admin_tariffs_router)
|
||||
router.include_router(admin_servers_router)
|
||||
@@ -83,6 +92,8 @@ router.include_router(admin_broadcasts_router)
|
||||
router.include_router(admin_promocodes_router)
|
||||
router.include_router(admin_promo_groups_router)
|
||||
router.include_router(admin_campaigns_router)
|
||||
router.include_router(admin_partners_router)
|
||||
router.include_router(admin_withdrawals_router)
|
||||
router.include_router(admin_users_router)
|
||||
router.include_router(admin_payment_methods_router)
|
||||
router.include_router(admin_payments_router)
|
||||
@@ -93,6 +104,11 @@ 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)
|
||||
router.include_router(admin_channels_router)
|
||||
router.include_router(admin_apps_router)
|
||||
router.include_router(admin_roles_router)
|
||||
router.include_router(admin_policies_router)
|
||||
router.include_router(admin_audit_log_router)
|
||||
|
||||
# WebSocket route
|
||||
router.include_router(websocket_router)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Admin routes for managing VPN applications in app-config.json."""
|
||||
"""Admin routes for managing RemnaWave app configuration."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -13,10 +12,10 @@ from app.database.models import User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
|
||||
|
||||
@@ -24,424 +23,6 @@ router = APIRouter(prefix='/admin/apps', tags=['Cabinet Admin Apps'])
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class LocalizedText(BaseModel):
|
||||
"""Localized text for multiple languages."""
|
||||
|
||||
en: str = ''
|
||||
ru: str = ''
|
||||
zh: str | None = ''
|
||||
fa: str | None = ''
|
||||
|
||||
|
||||
class AppButton(BaseModel):
|
||||
"""Button with link and localized text."""
|
||||
|
||||
buttonLink: str
|
||||
buttonText: LocalizedText
|
||||
|
||||
|
||||
class AppStep(BaseModel):
|
||||
"""Step with description and optional buttons/title."""
|
||||
|
||||
description: LocalizedText
|
||||
buttons: list[AppButton] | None = None
|
||||
title: LocalizedText | None = None
|
||||
|
||||
|
||||
class AppDefinition(BaseModel):
|
||||
"""VPN application definition."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
isFeatured: bool = False
|
||||
urlScheme: str
|
||||
isNeedBase64Encoding: bool | None = None
|
||||
installationStep: AppStep
|
||||
addSubscriptionStep: AppStep
|
||||
connectAndUseStep: AppStep
|
||||
additionalBeforeAddSubscriptionStep: AppStep | None = None
|
||||
additionalAfterAddSubscriptionStep: AppStep | None = None
|
||||
|
||||
|
||||
class PlatformApps(BaseModel):
|
||||
"""Apps for a specific platform."""
|
||||
|
||||
platform: str
|
||||
apps: list[AppDefinition]
|
||||
|
||||
|
||||
class AppConfigBranding(BaseModel):
|
||||
"""Branding configuration."""
|
||||
|
||||
name: str
|
||||
logoUrl: str
|
||||
supportUrl: str
|
||||
|
||||
|
||||
class AppConfigConfig(BaseModel):
|
||||
"""Top-level config section."""
|
||||
|
||||
additionalLocales: list[str]
|
||||
branding: AppConfigBranding
|
||||
|
||||
|
||||
class AppConfigResponse(BaseModel):
|
||||
"""Full app config response."""
|
||||
|
||||
config: AppConfigConfig
|
||||
platforms: dict[str, list[AppDefinition]]
|
||||
|
||||
|
||||
class CreateAppRequest(BaseModel):
|
||||
"""Request to create a new app."""
|
||||
|
||||
platform: str
|
||||
app: AppDefinition
|
||||
|
||||
|
||||
class UpdateAppRequest(BaseModel):
|
||||
"""Request to update an app."""
|
||||
|
||||
app: AppDefinition
|
||||
|
||||
|
||||
class ReorderAppsRequest(BaseModel):
|
||||
"""Request to reorder apps in a platform."""
|
||||
|
||||
app_ids: list[str]
|
||||
|
||||
|
||||
class UpdateBrandingRequest(BaseModel):
|
||||
"""Request to update branding."""
|
||||
|
||||
branding: AppConfigBranding
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
|
||||
def _get_config_path() -> Path:
|
||||
"""Get path to app-config.json."""
|
||||
return Path(settings.get_app_config_path())
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""Load app config from file."""
|
||||
config_path = _get_config_path()
|
||||
if not config_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'App config file not found: {config_path}',
|
||||
)
|
||||
|
||||
try:
|
||||
with open(config_path, encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to parse app config: {e}',
|
||||
)
|
||||
|
||||
|
||||
def _save_config(config: dict) -> None:
|
||||
"""Save app config to file."""
|
||||
config_path = _get_config_path()
|
||||
|
||||
try:
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to save app config: {e}',
|
||||
)
|
||||
|
||||
|
||||
VALID_PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=AppConfigResponse)
|
||||
async def get_app_config(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get full app configuration."""
|
||||
config = _load_config()
|
||||
return config
|
||||
|
||||
|
||||
@router.get('/platforms', response_model=list[str])
|
||||
async def get_platforms(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get list of available platforms."""
|
||||
return VALID_PLATFORMS
|
||||
|
||||
|
||||
@router.get('/platforms/{platform}', response_model=list[AppDefinition])
|
||||
async def get_platform_apps(
|
||||
platform: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get apps for a specific platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}. Valid platforms: {VALID_PLATFORMS}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
return platforms.get(platform, [])
|
||||
|
||||
|
||||
@router.post('/platforms/{platform}', response_model=AppDefinition)
|
||||
async def create_app(
|
||||
platform: str,
|
||||
request: CreateAppRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Create a new app for a platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
|
||||
if platform not in platforms:
|
||||
platforms[platform] = []
|
||||
|
||||
# Check if app with same ID already exists
|
||||
existing_ids = [app.get('id') for app in platforms[platform]]
|
||||
if request.app.id in existing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"App with ID '{request.app.id}' already exists in {platform}",
|
||||
)
|
||||
|
||||
# Add new app
|
||||
app_dict = request.app.model_dump(exclude_none=True)
|
||||
platforms[platform].append(app_dict)
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} created app '{request.app.id}' for platform '{platform}'")
|
||||
|
||||
return request.app
|
||||
|
||||
|
||||
@router.put('/platforms/{platform}/{app_id}', response_model=AppDefinition)
|
||||
async def update_app(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
request: UpdateAppRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Update an existing app."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
apps = platforms.get(platform, [])
|
||||
|
||||
# Find and update app
|
||||
app_index = None
|
||||
for i, app in enumerate(apps):
|
||||
if app.get('id') == app_id:
|
||||
app_index = i
|
||||
break
|
||||
|
||||
if app_index is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
# Update app
|
||||
app_dict = request.app.model_dump(exclude_none=True)
|
||||
apps[app_index] = app_dict
|
||||
platforms[platform] = apps
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} updated app '{app_id}' in platform '{platform}'")
|
||||
|
||||
return request.app
|
||||
|
||||
|
||||
@router.delete('/platforms/{platform}/{app_id}')
|
||||
async def delete_app(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Delete an app from a platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
apps = platforms.get(platform, [])
|
||||
|
||||
# Find and remove app
|
||||
original_length = len(apps)
|
||||
apps = [app for app in apps if app.get('id') != app_id]
|
||||
|
||||
if len(apps) == original_length:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
platforms[platform] = apps
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} deleted app '{app_id}' from platform '{platform}'")
|
||||
|
||||
return {'status': 'deleted', 'app_id': app_id}
|
||||
|
||||
|
||||
@router.post('/platforms/{platform}/reorder')
|
||||
async def reorder_apps(
|
||||
platform: str,
|
||||
request: ReorderAppsRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Reorder apps in a platform."""
|
||||
if platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid platform: {platform}',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
apps = platforms.get(platform, [])
|
||||
|
||||
# Create a map of apps by ID
|
||||
apps_map = {app.get('id'): app for app in apps}
|
||||
|
||||
# Verify all IDs exist
|
||||
for app_id in request.app_ids:
|
||||
if app_id not in apps_map:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
# Reorder apps
|
||||
reordered_apps = [apps_map[app_id] for app_id in request.app_ids]
|
||||
|
||||
# Add any apps that weren't in the reorder list (shouldn't happen but just in case)
|
||||
for app in apps:
|
||||
if app.get('id') not in request.app_ids:
|
||||
reordered_apps.append(app)
|
||||
|
||||
platforms[platform] = reordered_apps
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} reordered apps in platform '{platform}'")
|
||||
|
||||
return {'status': 'reordered', 'order': request.app_ids}
|
||||
|
||||
|
||||
@router.put('/branding', response_model=AppConfigBranding)
|
||||
async def update_branding(
|
||||
request: UpdateBrandingRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Update branding configuration."""
|
||||
config = _load_config()
|
||||
|
||||
if 'config' not in config:
|
||||
config['config'] = {}
|
||||
|
||||
config['config']['branding'] = request.branding.model_dump()
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f'Admin {admin.id} updated branding')
|
||||
|
||||
return request.branding
|
||||
|
||||
|
||||
@router.get('/branding', response_model=AppConfigBranding)
|
||||
async def get_branding(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Get branding configuration."""
|
||||
config = _load_config()
|
||||
branding = config.get('config', {}).get('branding', {})
|
||||
return branding
|
||||
|
||||
|
||||
@router.post('/platforms/{platform}/copy/{app_id}')
|
||||
async def copy_app_to_platform(
|
||||
platform: str,
|
||||
app_id: str,
|
||||
target_platform: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""Copy an app from one platform to another."""
|
||||
if platform not in VALID_PLATFORMS or target_platform not in VALID_PLATFORMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid platform(s)',
|
||||
)
|
||||
|
||||
config = _load_config()
|
||||
platforms = config.get('platforms', {})
|
||||
source_apps = platforms.get(platform, [])
|
||||
|
||||
# Find source app
|
||||
source_app = None
|
||||
for app in source_apps:
|
||||
if app.get('id') == app_id:
|
||||
source_app = app.copy()
|
||||
break
|
||||
|
||||
if not source_app:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"App '{app_id}' not found in platform '{platform}'",
|
||||
)
|
||||
|
||||
# Generate new ID for copied app
|
||||
import time
|
||||
|
||||
new_id = f'{app_id}-copy-{int(time.time())}'
|
||||
source_app['id'] = new_id
|
||||
|
||||
# Add to target platform
|
||||
if target_platform not in platforms:
|
||||
platforms[target_platform] = []
|
||||
|
||||
platforms[target_platform].append(source_app)
|
||||
config['platforms'] = platforms
|
||||
|
||||
_save_config(config)
|
||||
logger.info(f"Admin {admin.id} copied app '{app_id}' from '{platform}' to '{target_platform}' as '{new_id}'")
|
||||
|
||||
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
|
||||
|
||||
|
||||
# ============ RemnaWave Config Routes ============
|
||||
|
||||
|
||||
class RemnaWaveConfigStatus(BaseModel):
|
||||
"""Status of RemnaWave config integration."""
|
||||
|
||||
@@ -455,6 +36,11 @@ class UpdateRemnaWaveUuidRequest(BaseModel):
|
||||
uuid: str | None = None
|
||||
|
||||
|
||||
# ============ Helpers ============
|
||||
|
||||
_UUID_PATTERN = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
|
||||
|
||||
|
||||
def _get_remnawave_config_uuid() -> str | None:
|
||||
"""Get RemnaWave config UUID from system settings or env."""
|
||||
try:
|
||||
@@ -463,9 +49,12 @@ def _get_remnawave_config_uuid() -> str | None:
|
||||
return settings.CABINET_REMNA_SUB_CONFIG
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/remnawave/status', response_model=RemnaWaveConfigStatus)
|
||||
async def get_remnawave_config_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('apps:read')),
|
||||
):
|
||||
"""Get RemnaWave config integration status."""
|
||||
config_uuid = _get_remnawave_config_uuid()
|
||||
@@ -478,29 +67,28 @@ async def get_remnawave_config_status(
|
||||
@router.put('/remnawave/uuid', response_model=RemnaWaveConfigStatus)
|
||||
async def set_remnawave_config_uuid(
|
||||
request: UpdateRemnaWaveUuidRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('apps:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Set RemnaWave subscription config UUID."""
|
||||
uuid_value = request.uuid.strip() if request.uuid else None
|
||||
|
||||
# Validate UUID format if provided
|
||||
if uuid_value:
|
||||
import re
|
||||
|
||||
uuid_pattern = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
|
||||
if not uuid_pattern.match(uuid_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid UUID format',
|
||||
)
|
||||
if uuid_value and not _UUID_PATTERN.match(uuid_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Invalid UUID format',
|
||||
)
|
||||
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid_value)
|
||||
await db.commit()
|
||||
logger.info(f"Admin {admin.id} updated CABINET_REMNA_SUB_CONFIG to '{uuid_value}'")
|
||||
|
||||
from app.handlers.subscription.common import invalidate_app_config_cache
|
||||
|
||||
invalidate_app_config_cache()
|
||||
logger.info('Admin updated CABINET_REMNA_SUB_CONFIG', 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',
|
||||
@@ -514,17 +102,14 @@ async def set_remnawave_config_uuid(
|
||||
|
||||
@router.get('/remnawave/config')
|
||||
async def get_remnawave_subscription_config(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('apps:read')),
|
||||
):
|
||||
"""
|
||||
Fetch subscription page config from RemnaWave panel.
|
||||
Uses CABINET_REMNA_SUB_CONFIG setting for the config UUID.
|
||||
"""
|
||||
"""Fetch subscription page config from RemnaWave panel."""
|
||||
config_uuid = _get_remnawave_config_uuid()
|
||||
if not config_uuid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='CABINET_REMNA_SUB_CONFIG is not configured',
|
||||
detail='RemnaWave subscription config is not configured',
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -534,10 +119,9 @@ async def get_remnawave_subscription_config(
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Subscription config '{config_uuid}' not found in RemnaWave",
|
||||
detail='Subscription config not found',
|
||||
)
|
||||
|
||||
# Return the raw config data from RemnaWave
|
||||
return {
|
||||
'uuid': config.uuid,
|
||||
'name': config.name,
|
||||
@@ -547,16 +131,16 @@ 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}',
|
||||
detail='Failed to fetch config from RemnaWave',
|
||||
)
|
||||
|
||||
|
||||
@router.get('/remnawave/configs')
|
||||
async def list_remnawave_subscription_configs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('apps:read')),
|
||||
):
|
||||
"""List available subscription page configs from RemnaWave panel."""
|
||||
try:
|
||||
@@ -572,8 +156,8 @@ 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}',
|
||||
detail='Failed to fetch configs from RemnaWave',
|
||||
)
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Admin audit log routes — view and export admin action history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AuditLogCRUD
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/rbac/audit-log', tags=['Admin RBAC Audit Log'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
"""Single audit log entry."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
action: str
|
||||
resource_type: str | None = None
|
||||
resource_id: str | None = None
|
||||
details: dict[str, Any] | None = None
|
||||
ip_address: str | None = None
|
||||
user_agent: str | None = None
|
||||
status: str
|
||||
request_method: str | None = None
|
||||
request_path: str | None = None
|
||||
created_at: datetime | None = None
|
||||
user_first_name: str | None = None
|
||||
user_email: str | None = None
|
||||
|
||||
|
||||
class AuditLogListResponse(BaseModel):
|
||||
"""Paginated audit log list."""
|
||||
|
||||
items: list[AuditLogEntry]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
# ============ CSV Export ============
|
||||
|
||||
_CSV_COLUMNS = [
|
||||
'id',
|
||||
'user_id',
|
||||
'action',
|
||||
'resource_type',
|
||||
'resource_id',
|
||||
'status',
|
||||
'ip_address',
|
||||
'request_method',
|
||||
'request_path',
|
||||
'created_at',
|
||||
'user_agent',
|
||||
'details',
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_csv_cell(value: str) -> str:
|
||||
"""Prevent CSV formula injection by prefixing dangerous leading characters."""
|
||||
if value and value[0] in ('=', '+', '-', '@', '\t', '\r'):
|
||||
return f"'{value}"
|
||||
return value
|
||||
|
||||
|
||||
def _logs_to_csv(logs) -> str:
|
||||
"""Serialize audit log entries to CSV string."""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(_CSV_COLUMNS)
|
||||
|
||||
for log in logs:
|
||||
writer.writerow(
|
||||
[
|
||||
log.id,
|
||||
log.user_id,
|
||||
log.action,
|
||||
log.resource_type or '',
|
||||
log.resource_id or '',
|
||||
log.status,
|
||||
log.ip_address or '',
|
||||
log.request_method or '',
|
||||
_sanitize_csv_cell(log.request_path or ''),
|
||||
log.created_at.isoformat() if log.created_at else '',
|
||||
_sanitize_csv_cell((log.user_agent or '')[:200]),
|
||||
_sanitize_csv_cell(str(log.details) if log.details else ''),
|
||||
]
|
||||
)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=AuditLogListResponse)
|
||||
async def list_audit_logs(
|
||||
admin: User = Depends(require_permission('audit_log:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
user_id: int | None = Query(default=None),
|
||||
action: str | None = Query(default=None),
|
||||
resource_type: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None),
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
"""List audit log entries with optional filters and pagination."""
|
||||
logs, total = await AuditLogCRUD.get_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
status=status,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
load_user=True,
|
||||
)
|
||||
|
||||
items = [
|
||||
AuditLogEntry(
|
||||
id=log.id,
|
||||
user_id=log.user_id,
|
||||
action=log.action,
|
||||
resource_type=log.resource_type,
|
||||
resource_id=log.resource_id,
|
||||
details=log.details,
|
||||
ip_address=log.ip_address,
|
||||
user_agent=log.user_agent,
|
||||
status=log.status,
|
||||
request_method=log.request_method,
|
||||
request_path=log.request_path,
|
||||
created_at=log.created_at,
|
||||
user_first_name=log.user.first_name if log.user else None,
|
||||
user_email=log.user.email if log.user else None,
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
|
||||
return AuditLogListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/export')
|
||||
async def export_audit_logs(
|
||||
admin: User = Depends(require_permission('audit_log:export')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
user_id: int | None = Query(default=None),
|
||||
action: str | None = Query(default=None),
|
||||
resource_type: str | None = Query(default=None),
|
||||
status: str | None = Query(default=None),
|
||||
date_from: datetime | None = Query(default=None),
|
||||
date_to: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=10000, ge=1, le=50000),
|
||||
):
|
||||
"""Export audit logs as CSV file."""
|
||||
logs, _total = await AuditLogCRUD.get_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
status=status,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
csv_content = _logs_to_csv(logs)
|
||||
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'audit_log_{timestamp}.csv'
|
||||
|
||||
logger.info(
|
||||
'Admin exported audit logs',
|
||||
admin_id=admin.id,
|
||||
rows=len(logs),
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([csv_content]),
|
||||
media_type='text/csv',
|
||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||
)
|
||||
@@ -1,15 +1,15 @@
|
||||
"""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
|
||||
from app.database.models import User
|
||||
from app.external.ban_system_api import BanSystemAPI, BanSystemAPIError
|
||||
|
||||
from ..dependencies import get_current_admin_user
|
||||
from ..dependencies import require_permission
|
||||
from ..schemas.ban_system import (
|
||||
BanAgentHistoryItem,
|
||||
BanAgentHistoryResponse,
|
||||
@@ -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}',
|
||||
@@ -101,7 +103,7 @@ async def _api_request(api: BanSystemAPI, method: str, *args, **kwargs) -> Any:
|
||||
|
||||
@router.get('/status', response_model=BanSystemStatusResponse)
|
||||
async def get_ban_system_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanSystemStatusResponse:
|
||||
"""Get Ban System integration status."""
|
||||
return BanSystemStatusResponse(
|
||||
@@ -115,7 +117,7 @@ async def get_ban_system_status(
|
||||
|
||||
@router.get('/stats/raw')
|
||||
async def get_stats_raw(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> dict:
|
||||
"""Get raw stats from Ban System API for debugging."""
|
||||
api = _get_ban_api()
|
||||
@@ -125,7 +127,7 @@ async def get_stats_raw(
|
||||
|
||||
@router.get('/stats', response_model=BanSystemStatsResponse)
|
||||
async def get_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanSystemStatsResponse:
|
||||
"""Get overall Ban System statistics."""
|
||||
from datetime import datetime
|
||||
@@ -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 {}
|
||||
@@ -179,7 +181,7 @@ async def get_users(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
status: str | None = Query(None, description='Filter: over_limit, with_limit, unlimited'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanUsersListResponse:
|
||||
"""Get list of users from Ban System."""
|
||||
api = _get_ban_api()
|
||||
@@ -209,7 +211,7 @@ async def get_users(
|
||||
@router.get('/users/over-limit', response_model=BanUsersListResponse)
|
||||
async def get_users_over_limit(
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanUsersListResponse:
|
||||
"""Get users who exceeded their device limit."""
|
||||
api = _get_ban_api()
|
||||
@@ -239,7 +241,7 @@ async def get_users_over_limit(
|
||||
@router.get('/users/search/{query}')
|
||||
async def search_users(
|
||||
query: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanUsersListResponse:
|
||||
"""Search for users."""
|
||||
api = _get_ban_api()
|
||||
@@ -270,7 +272,7 @@ async def search_users(
|
||||
@router.get('/users/{email}', response_model=BanUserDetailResponse)
|
||||
async def get_user_detail(
|
||||
email: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanUserDetailResponse:
|
||||
"""Get detailed user information."""
|
||||
api = _get_ban_api()
|
||||
@@ -323,7 +325,7 @@ async def get_user_detail(
|
||||
|
||||
@router.get('/punishments', response_model=BanPunishmentsListResponse)
|
||||
async def get_punishments(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanPunishmentsListResponse:
|
||||
"""Get list of active punishments (bans)."""
|
||||
api = _get_ban_api()
|
||||
@@ -358,13 +360,13 @@ async def get_punishments(
|
||||
@router.post('/punishments/{user_id}/unban', response_model=UnbanResponse)
|
||||
async def unban_user(
|
||||
user_id: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:unban')),
|
||||
) -> UnbanResponse:
|
||||
"""Unban (enable) a 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
|
||||
@@ -375,7 +377,7 @@ async def unban_user(
|
||||
@router.post('/ban', response_model=UnbanResponse)
|
||||
async def ban_user(
|
||||
request: BanUserRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:ban')),
|
||||
) -> UnbanResponse:
|
||||
"""Manually ban a user."""
|
||||
api = _get_ban_api()
|
||||
@@ -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
|
||||
@@ -399,7 +401,7 @@ async def ban_user(
|
||||
async def get_punishment_history(
|
||||
query: str,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanHistoryResponse:
|
||||
"""Get punishment history for a user."""
|
||||
api = _get_ban_api()
|
||||
@@ -436,7 +438,7 @@ async def get_punishment_history(
|
||||
|
||||
@router.get('/nodes', response_model=BanNodesListResponse)
|
||||
async def get_nodes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanNodesListResponse:
|
||||
"""Get list of connected nodes."""
|
||||
api = _get_ban_api()
|
||||
@@ -478,7 +480,7 @@ async def get_agents(
|
||||
search: str | None = Query(None),
|
||||
health: str | None = Query(None, description='healthy, warning, critical'),
|
||||
agent_status: str | None = Query(None, alias='status', description='online, offline'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanAgentsListResponse:
|
||||
"""Get list of monitoring agents."""
|
||||
api = _get_ban_api()
|
||||
@@ -577,7 +579,7 @@ async def get_agents(
|
||||
|
||||
@router.get('/agents/summary', response_model=BanAgentsSummary)
|
||||
async def get_agents_summary(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanAgentsSummary:
|
||||
"""Get agents summary statistics."""
|
||||
api = _get_ban_api()
|
||||
@@ -601,7 +603,7 @@ async def get_agents_summary(
|
||||
@router.get('/traffic/violations', response_model=BanTrafficViolationsResponse)
|
||||
async def get_traffic_violations(
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanTrafficViolationsResponse:
|
||||
"""Get list of traffic limit violations."""
|
||||
api = _get_ban_api()
|
||||
@@ -635,7 +637,7 @@ async def get_traffic_violations(
|
||||
|
||||
@router.get('/traffic', response_model=BanTrafficResponse)
|
||||
async def get_traffic(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanTrafficResponse:
|
||||
"""Get full traffic statistics including top users."""
|
||||
api = _get_ban_api()
|
||||
@@ -679,7 +681,7 @@ async def get_traffic(
|
||||
@router.get('/traffic/top')
|
||||
async def get_traffic_top(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> list[BanTrafficTopItem]:
|
||||
"""Get top users by traffic."""
|
||||
api = _get_ban_api()
|
||||
@@ -742,7 +744,7 @@ def _parse_setting_response(key: str, data: Any, default_type: str = 'str') -> B
|
||||
|
||||
@router.get('/settings', response_model=BanSettingsResponse)
|
||||
async def get_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanSettingsResponse:
|
||||
"""Get all Ban System settings."""
|
||||
api = _get_ban_api()
|
||||
@@ -800,7 +802,7 @@ async def get_settings(
|
||||
@router.get('/settings/{key}')
|
||||
async def get_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanSettingDefinition:
|
||||
"""Get a specific setting."""
|
||||
api = _get_ban_api()
|
||||
@@ -813,13 +815,13 @@ async def get_setting(
|
||||
async def set_setting(
|
||||
key: str,
|
||||
value: str = Query(...),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
) -> BanSettingDefinition:
|
||||
"""Set a setting value."""
|
||||
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)
|
||||
|
||||
@@ -827,13 +829,13 @@ async def set_setting(
|
||||
@router.post('/settings/{key}/toggle')
|
||||
async def toggle_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
) -> BanSettingDefinition:
|
||||
"""Toggle a boolean 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')
|
||||
|
||||
@@ -844,13 +846,13 @@ async def toggle_setting(
|
||||
@router.post('/settings/whitelist/add', response_model=UnbanResponse)
|
||||
async def whitelist_add(
|
||||
request: BanWhitelistRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
) -> UnbanResponse:
|
||||
"""Add user to whitelist."""
|
||||
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
|
||||
@@ -861,13 +863,13 @@ async def whitelist_add(
|
||||
@router.post('/settings/whitelist/remove', response_model=UnbanResponse)
|
||||
async def whitelist_remove(
|
||||
request: BanWhitelistRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:edit')),
|
||||
) -> UnbanResponse:
|
||||
"""Remove user from whitelist."""
|
||||
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
|
||||
@@ -881,7 +883,7 @@ async def whitelist_remove(
|
||||
@router.get('/report', response_model=BanReportResponse)
|
||||
async def get_report(
|
||||
hours: int = Query(24, ge=1, le=168),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanReportResponse:
|
||||
"""Get period report."""
|
||||
api = _get_ban_api()
|
||||
@@ -911,7 +913,7 @@ async def get_report(
|
||||
|
||||
@router.get('/health', response_model=BanHealthResponse)
|
||||
async def get_health(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanHealthResponse:
|
||||
"""Get Ban System health status."""
|
||||
api = _get_ban_api()
|
||||
@@ -945,7 +947,7 @@ async def get_health(
|
||||
|
||||
@router.get('/health/detailed', response_model=BanHealthDetailedResponse)
|
||||
async def get_health_detailed(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanHealthDetailedResponse:
|
||||
"""Get detailed health information."""
|
||||
api = _get_ban_api()
|
||||
@@ -965,7 +967,7 @@ async def get_health_detailed(
|
||||
async def get_agent_history(
|
||||
node_name: str,
|
||||
hours: int = Query(24, ge=1, le=168),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanAgentHistoryResponse:
|
||||
"""Get agent statistics history."""
|
||||
api = _get_ban_api()
|
||||
@@ -1001,7 +1003,7 @@ async def get_agent_history(
|
||||
async def get_user_punishment_history(
|
||||
email: str,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('ban_system:read')),
|
||||
) -> BanHistoryResponse:
|
||||
"""Get punishment history for a specific user."""
|
||||
api = _get_ban_api()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Admin routes for broadcasts in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import distinct, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -18,7 +18,7 @@ from app.services.broadcast_service import (
|
||||
email_broadcast_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.broadcasts import (
|
||||
BroadcastButton,
|
||||
BroadcastButtonsResponse,
|
||||
@@ -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'])
|
||||
|
||||
@@ -118,9 +118,10 @@ EMAIL_FILTER_GROUPS = {
|
||||
|
||||
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
"""Serialize broadcast to response model."""
|
||||
blocked = broadcast.blocked_count or 0
|
||||
progress = 0.0
|
||||
if broadcast.total_count > 0:
|
||||
progress = round((broadcast.sent_count + broadcast.failed_count) / broadcast.total_count * 100, 1)
|
||||
progress = round((broadcast.sent_count + broadcast.failed_count + blocked) / broadcast.total_count * 100, 1)
|
||||
|
||||
return BroadcastResponse(
|
||||
id=broadcast.id,
|
||||
@@ -133,6 +134,7 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
|
||||
total_count=broadcast.total_count,
|
||||
sent_count=broadcast.sent_count,
|
||||
failed_count=broadcast.failed_count,
|
||||
blocked_count=blocked,
|
||||
status=broadcast.status,
|
||||
admin_id=broadcast.admin_id,
|
||||
admin_name=broadcast.admin_name,
|
||||
@@ -245,7 +247,7 @@ def _validate_buttons(buttons: list[str]) -> bool:
|
||||
|
||||
@router.get('/filters', response_model=BroadcastFiltersResponse)
|
||||
async def get_filters(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastFiltersResponse:
|
||||
"""Get all available filters with user counts."""
|
||||
@@ -255,7 +257,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 +274,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(
|
||||
@@ -308,7 +310,7 @@ async def get_filters(
|
||||
|
||||
@router.get('/tariffs', response_model=BroadcastTariffsResponse)
|
||||
async def get_tariffs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastTariffsResponse:
|
||||
"""Get tariffs for broadcast filtering."""
|
||||
@@ -331,7 +333,7 @@ async def get_tariffs(
|
||||
|
||||
@router.get('/buttons', response_model=BroadcastButtonsResponse)
|
||||
async def get_buttons(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
) -> BroadcastButtonsResponse:
|
||||
"""Get available buttons for broadcasts."""
|
||||
default_buttons = set(DEFAULT_BROADCAST_BUTTONS)
|
||||
@@ -350,7 +352,7 @@ async def get_buttons(
|
||||
@router.post('/preview', response_model=BroadcastPreviewResponse)
|
||||
async def preview_broadcast(
|
||||
request: BroadcastPreviewRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastPreviewResponse:
|
||||
"""Preview broadcast recipients count."""
|
||||
@@ -367,7 +369,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',
|
||||
@@ -379,7 +381,7 @@ async def preview_broadcast(
|
||||
@router.post('', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_broadcast(
|
||||
request: BroadcastCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Create and start a broadcast."""
|
||||
@@ -450,14 +452,16 @@ 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)
|
||||
|
||||
|
||||
@router.get('', response_model=BroadcastListResponse)
|
||||
async def list_broadcasts(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -483,7 +487,7 @@ async def list_broadcasts(
|
||||
|
||||
@router.get('/email-filters', response_model=EmailFiltersResponse)
|
||||
async def get_email_filters(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailFiltersResponse:
|
||||
"""Get all available email filters with user counts."""
|
||||
@@ -494,7 +498,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(
|
||||
@@ -519,7 +523,7 @@ async def get_email_filters(
|
||||
@router.post('/email-preview', response_model=EmailPreviewResponse)
|
||||
async def preview_email_broadcast(
|
||||
request: EmailPreviewRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> EmailPreviewResponse:
|
||||
"""Preview email broadcast recipients count."""
|
||||
@@ -532,7 +536,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',
|
||||
@@ -544,7 +548,7 @@ async def preview_email_broadcast(
|
||||
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_combined_broadcast(
|
||||
request: CombinedBroadcastCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:send')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Create and start a combined broadcast (telegram/email/both)."""
|
||||
@@ -661,7 +665,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)
|
||||
|
||||
@@ -669,7 +679,7 @@ async def create_combined_broadcast(
|
||||
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
|
||||
async def get_broadcast(
|
||||
broadcast_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Get broadcast details."""
|
||||
@@ -685,7 +695,7 @@ async def get_broadcast(
|
||||
@router.post('/{broadcast_id}/stop', response_model=BroadcastResponse)
|
||||
async def stop_broadcast(
|
||||
broadcast_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('broadcasts:send')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> BroadcastResponse:
|
||||
"""Stop a running broadcast (telegram or email)."""
|
||||
@@ -716,11 +726,11 @@ async def stop_broadcast(
|
||||
broadcast.status = 'cancelling'
|
||||
else:
|
||||
broadcast.status = 'cancelled'
|
||||
broadcast.completed_at = datetime.utcnow()
|
||||
broadcast.completed_at = datetime.now(UTC)
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Admin routes for per-section cabinet button style configuration."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -17,10 +17,10 @@ from app.utils.button_styles_cache import (
|
||||
load_button_styles_cache,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
|
||||
|
||||
@@ -112,7 +112,7 @@ def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
|
||||
|
||||
@router.get('', response_model=ButtonStylesResponse)
|
||||
async def get_button_styles(
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('settings:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return current per-section button styles. Admin only."""
|
||||
@@ -145,7 +145,7 @@ async def get_button_styles(
|
||||
@router.patch('', response_model=ButtonStylesResponse)
|
||||
async def update_button_styles(
|
||||
payload: ButtonStylesUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Partially update per-section button styles. Admin only."""
|
||||
@@ -234,20 +234,22 @@ async def update_button_styles(
|
||||
# Refresh in-process cache
|
||||
await load_button_styles_cache()
|
||||
|
||||
logger.info('Admin %s updated button styles for sections: %s', admin.telegram_id, changed_sections)
|
||||
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),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
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 %s reset button styles to defaults', admin.telegram_id)
|
||||
logger.info('Admin reset button styles to defaults', telegram_id=admin.telegram_id)
|
||||
|
||||
return _build_response(DEFAULT_BUTTON_STYLES)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Admin routes for managing advertising campaigns in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -21,14 +22,17 @@ from app.database.crud.campaign import (
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import get_all_tariffs
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
AdvertisingCampaignRegistration,
|
||||
PartnerStatus,
|
||||
Subscription,
|
||||
Tariff,
|
||||
User,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.campaigns import (
|
||||
AvailablePartnerItem,
|
||||
CampaignCreateRequest,
|
||||
CampaignDetailResponse,
|
||||
CampaignListItem,
|
||||
@@ -45,7 +49,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'])
|
||||
|
||||
@@ -58,9 +62,25 @@ def _get_deep_link(start_parameter: str) -> str:
|
||||
return f'?start={start_parameter}'
|
||||
|
||||
|
||||
def _get_web_link(start_parameter: str) -> str | None:
|
||||
"""Generate web link for campaign."""
|
||||
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
|
||||
if base_url:
|
||||
return f'{base_url}/?campaign={start_parameter}'
|
||||
return None
|
||||
|
||||
|
||||
def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
|
||||
"""Get partner display name from campaign."""
|
||||
if not campaign.partner_user_id or not campaign.partner:
|
||||
return None
|
||||
partner = campaign.partner
|
||||
return partner.first_name or partner.username or f'#{partner.id}'
|
||||
|
||||
|
||||
@router.get('/overview', response_model=CampaignsOverviewResponse)
|
||||
async def get_overview(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get campaigns overview statistics."""
|
||||
@@ -88,7 +108,7 @@ async def get_overview(
|
||||
|
||||
@router.get('/available-servers', response_model=list[ServerSquadInfo])
|
||||
async def get_available_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available server squads for campaign subscription bonus."""
|
||||
@@ -106,7 +126,7 @@ async def get_available_servers(
|
||||
|
||||
@router.get('/available-tariffs', response_model=list[TariffListItem])
|
||||
async def get_available_tariffs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available tariffs for campaign tariff bonus."""
|
||||
@@ -133,12 +153,32 @@ async def get_available_tariffs(
|
||||
]
|
||||
|
||||
|
||||
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
|
||||
async def get_available_partners(
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of approved partners for campaign partner selector."""
|
||||
result = await db.execute(
|
||||
select(User).where(User.partner_status == PartnerStatus.APPROVED.value).order_by(User.first_name, User.username)
|
||||
)
|
||||
partners = result.scalars().all()
|
||||
return [
|
||||
AvailablePartnerItem(
|
||||
user_id=p.id,
|
||||
username=p.username,
|
||||
first_name=p.first_name,
|
||||
)
|
||||
for p in partners
|
||||
]
|
||||
|
||||
|
||||
@router.get('', response_model=CampaignListResponse)
|
||||
async def list_campaigns(
|
||||
include_inactive: bool = True,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all campaigns."""
|
||||
@@ -159,6 +199,8 @@ async def list_campaigns(
|
||||
registrations_count=stats['registrations'],
|
||||
total_revenue_kopeks=stats['total_revenue_kopeks'],
|
||||
conversion_rate=stats['conversion_rate'],
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
partner_name=_get_partner_name(campaign),
|
||||
created_at=campaign.created_at,
|
||||
)
|
||||
)
|
||||
@@ -169,7 +211,7 @@ async def list_campaigns(
|
||||
@router.get('/{campaign_id}', response_model=CampaignDetailResponse)
|
||||
async def get_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed campaign info."""
|
||||
@@ -202,17 +244,20 @@ async def get_campaign(
|
||||
tariff_id=campaign.tariff_id,
|
||||
tariff_duration_days=campaign.tariff_duration_days,
|
||||
tariff=tariff_info,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
partner_name=_get_partner_name(campaign),
|
||||
created_by=campaign.created_by,
|
||||
created_at=campaign.created_at,
|
||||
updated_at=campaign.updated_at,
|
||||
deep_link=_get_deep_link(campaign.start_parameter),
|
||||
web_link=_get_web_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{campaign_id}/stats', response_model=CampaignStatisticsResponse)
|
||||
async def get_campaign_stats(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:stats')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed campaign statistics."""
|
||||
@@ -249,6 +294,7 @@ async def get_campaign_stats(
|
||||
conversion_rate=stats['conversion_rate'],
|
||||
trial_conversion_rate=stats['trial_conversion_rate'],
|
||||
deep_link=_get_deep_link(campaign.start_parameter),
|
||||
web_link=_get_web_link(campaign.start_parameter),
|
||||
)
|
||||
|
||||
|
||||
@@ -257,7 +303,7 @@ async def get_campaign_registrations(
|
||||
campaign_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of users registered through campaign."""
|
||||
@@ -289,19 +335,22 @@ async def get_campaign_registrations(
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
items = []
|
||||
for reg, user in rows:
|
||||
# Check if user has subscription
|
||||
# Batch query: find which users have active subscriptions (avoids N+1)
|
||||
user_ids = [user.id for _reg, user in rows]
|
||||
active_sub_user_ids: set[int] = set()
|
||||
if user_ids:
|
||||
sub_result = await db.execute(
|
||||
select(Subscription)
|
||||
select(Subscription.user_id)
|
||||
.where(
|
||||
Subscription.user_id == user.id,
|
||||
Subscription.user_id.in_(user_ids),
|
||||
Subscription.status == 'active',
|
||||
)
|
||||
.limit(1)
|
||||
.distinct()
|
||||
)
|
||||
has_sub = sub_result.scalar_one_or_none() is not None
|
||||
active_sub_user_ids = set(sub_result.scalars().all())
|
||||
|
||||
items = []
|
||||
for reg, user in rows:
|
||||
items.append(
|
||||
CampaignRegistrationItem(
|
||||
id=reg.id,
|
||||
@@ -316,7 +365,7 @@ async def get_campaign_registrations(
|
||||
tariff_duration_days=reg.tariff_duration_days,
|
||||
created_at=reg.created_at,
|
||||
user_balance_kopeks=user.balance_kopeks or 0,
|
||||
has_subscription=has_sub,
|
||||
has_subscription=user.id in active_sub_user_ids,
|
||||
has_paid=user.has_had_paid_subscription or False,
|
||||
)
|
||||
)
|
||||
@@ -332,7 +381,7 @@ async def get_campaign_registrations(
|
||||
@router.post('', response_model=CampaignDetailResponse)
|
||||
async def create_new_campaign(
|
||||
request: CampaignCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new advertising campaign."""
|
||||
@@ -359,6 +408,15 @@ async def create_new_campaign(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Validate partner exists and is approved
|
||||
if request.partner_user_id is not None:
|
||||
partner_user = await db.get(User, request.partner_user_id)
|
||||
if not partner_user or partner_user.partner_status != 'approved':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Partner not found or not approved',
|
||||
)
|
||||
|
||||
campaign = await create_campaign(
|
||||
db,
|
||||
name=request.name,
|
||||
@@ -373,12 +431,13 @@ async def create_new_campaign(
|
||||
tariff_id=request.tariff_id,
|
||||
tariff_duration_days=request.tariff_duration_days,
|
||||
is_active=request.is_active,
|
||||
partner_user_id=request.partner_user_id,
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -387,7 +446,7 @@ async def create_new_campaign(
|
||||
async def update_existing_campaign(
|
||||
campaign_id: int,
|
||||
request: CampaignUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing campaign."""
|
||||
@@ -444,10 +503,28 @@ async def update_existing_campaign(
|
||||
if request.tariff_duration_days is not None:
|
||||
updates['tariff_duration_days'] = request.tariff_duration_days
|
||||
|
||||
# Handle partner_user_id separately (allows explicit None to unassign)
|
||||
partner_changed = False
|
||||
if 'partner_user_id' in request.model_fields_set:
|
||||
new_partner_id = request.partner_user_id
|
||||
if new_partner_id is not None:
|
||||
partner_user = await db.get(User, new_partner_id)
|
||||
if not partner_user or partner_user.partner_status != 'approved':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Partner not found or not approved',
|
||||
)
|
||||
campaign.partner_user_id = new_partner_id
|
||||
campaign.updated_at = datetime.now(UTC)
|
||||
partner_changed = True
|
||||
|
||||
if updates:
|
||||
await update_campaign(db, campaign, **updates)
|
||||
elif partner_changed:
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
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)
|
||||
|
||||
@@ -455,7 +532,7 @@ async def update_existing_campaign(
|
||||
@router.delete('/{campaign_id}')
|
||||
async def delete_existing_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a campaign."""
|
||||
@@ -475,7 +552,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'}
|
||||
|
||||
@@ -483,7 +560,7 @@ async def delete_existing_campaign(
|
||||
@router.post('/{campaign_id}/toggle', response_model=CampaignToggleResponse)
|
||||
async def toggle_campaign(
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle campaign active status."""
|
||||
@@ -498,7 +575,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,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Admin API for managing required channels."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.required_channel import (
|
||||
add_channel,
|
||||
delete_channel,
|
||||
get_all_channels,
|
||||
toggle_channel,
|
||||
update_channel,
|
||||
)
|
||||
from app.database.models import User
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.channel import (
|
||||
ChannelCreateRequest,
|
||||
ChannelListResponse,
|
||||
ChannelResponse,
|
||||
ChannelUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/channel-subscriptions', tags=['Cabinet Admin Channels'])
|
||||
|
||||
|
||||
@router.get('', response_model=ChannelListResponse)
|
||||
async def list_channels(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(require_permission('channels:read')),
|
||||
) -> ChannelListResponse:
|
||||
channels = await get_all_channels(db)
|
||||
return ChannelListResponse(
|
||||
items=[ChannelResponse.model_validate(ch) for ch in channels],
|
||||
total=len(channels),
|
||||
)
|
||||
|
||||
|
||||
@router.post('', response_model=ChannelResponse, status_code=201)
|
||||
async def create_channel(
|
||||
data: ChannelCreateRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> ChannelResponse:
|
||||
ch = await add_channel(
|
||||
db,
|
||||
channel_id=data.channel_id,
|
||||
channel_link=data.channel_link,
|
||||
title=data.title,
|
||||
disable_trial_on_leave=data.disable_trial_on_leave,
|
||||
disable_paid_on_leave=data.disable_paid_on_leave,
|
||||
)
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
return ChannelResponse.model_validate(ch)
|
||||
|
||||
|
||||
@router.patch('/{channel_db_id}', response_model=ChannelResponse)
|
||||
async def update_channel_endpoint(
|
||||
channel_db_id: int,
|
||||
data: ChannelUpdateRequest,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> ChannelResponse:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
ch = await update_channel(db, channel_db_id, **update_data)
|
||||
if not ch:
|
||||
raise HTTPException(status_code=404, detail='Channel not found')
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
return ChannelResponse.model_validate(ch)
|
||||
|
||||
|
||||
@router.post('/{channel_db_id}/toggle', response_model=ChannelResponse)
|
||||
async def toggle_channel_endpoint(
|
||||
channel_db_id: int,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> ChannelResponse:
|
||||
ch = await toggle_channel(db, channel_db_id)
|
||||
if not ch:
|
||||
raise HTTPException(status_code=404, detail='Channel not found')
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
return ChannelResponse.model_validate(ch)
|
||||
|
||||
|
||||
@router.delete('/{channel_db_id}', status_code=204)
|
||||
async def delete_channel_endpoint(
|
||||
channel_db_id: int,
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
_admin: User = Depends(require_permission('channels:edit')),
|
||||
) -> None:
|
||||
ok = await delete_channel(db, channel_db_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail='Channel not found')
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
@@ -1,16 +1,16 @@
|
||||
"""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
|
||||
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..services.email_template_overrides import (
|
||||
delete_template_override,
|
||||
get_all_overrides,
|
||||
@@ -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'])
|
||||
|
||||
@@ -370,7 +370,7 @@ class EmailTemplateSendTestRequest(BaseModel):
|
||||
|
||||
@router.get('', summary='List all email template types')
|
||||
async def list_template_types(
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('email_templates:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""List all available email template types with override status."""
|
||||
@@ -405,7 +405,7 @@ async def list_template_types(
|
||||
@router.get('/{notification_type}', summary='Get templates for a notification type')
|
||||
async def get_templates_for_type(
|
||||
notification_type: str,
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('email_templates:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Get all language templates for a specific notification type."""
|
||||
@@ -479,7 +479,7 @@ async def update_template(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
data: EmailTemplateUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('email_templates:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Save a custom email template override."""
|
||||
@@ -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}
|
||||
@@ -518,7 +515,7 @@ async def update_template(
|
||||
async def reset_template(
|
||||
notification_type: str,
|
||||
language: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('email_templates:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Delete custom template override, reverting to default."""
|
||||
@@ -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}
|
||||
@@ -546,7 +543,7 @@ async def reset_template(
|
||||
async def preview_template(
|
||||
notification_type: str,
|
||||
data: EmailTemplatePreviewRequest,
|
||||
_admin: User = Depends(get_current_admin_user),
|
||||
_admin: User = Depends(require_permission('email_templates:read')),
|
||||
) -> dict[str, Any]:
|
||||
"""Preview a rendered email template with sample data."""
|
||||
valid_types = [t['type'] for t in TEMPLATE_TYPES]
|
||||
@@ -591,7 +588,7 @@ async def preview_template(
|
||||
async def send_test_email(
|
||||
notification_type: str,
|
||||
data: EmailTemplateSendTestRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('email_templates:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Send a test email to the admin's email address."""
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
"""Admin routes for managing partners in cabinet."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import (
|
||||
AdvertisingCampaign,
|
||||
PartnerApplication,
|
||||
PartnerStatus,
|
||||
ReferralEarning,
|
||||
User,
|
||||
)
|
||||
from app.services.partner_application_service import partner_application_service
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.partners import (
|
||||
AdminApproveRequest,
|
||||
AdminPartnerApplicationItem,
|
||||
AdminPartnerApplicationsResponse,
|
||||
AdminPartnerDetailResponse,
|
||||
AdminPartnerItem,
|
||||
AdminPartnerListResponse,
|
||||
AdminRejectRequest,
|
||||
AdminUpdateCommissionRequest,
|
||||
CampaignSummary,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/partners', tags=['Cabinet Admin Partners'])
|
||||
|
||||
|
||||
# ==================== Settings ====================
|
||||
|
||||
|
||||
class PartnerSettingsResponse(BaseModel):
|
||||
withdrawal_enabled: bool
|
||||
withdrawal_min_amount_kopeks: int
|
||||
withdrawal_cooldown_days: int
|
||||
withdrawal_requisites_text: str
|
||||
partner_section_visible: bool
|
||||
referral_program_enabled: bool
|
||||
|
||||
|
||||
class PartnerSettingsUpdateRequest(BaseModel):
|
||||
withdrawal_enabled: bool | None = None
|
||||
withdrawal_min_amount_kopeks: int | None = Field(None, ge=0, le=100_000_000)
|
||||
withdrawal_cooldown_days: int | None = Field(None, ge=0, le=365)
|
||||
withdrawal_requisites_text: str | None = Field(None, max_length=2000)
|
||||
partner_section_visible: bool | None = None
|
||||
referral_program_enabled: bool | None = None
|
||||
|
||||
|
||||
def _build_partner_settings_response() -> PartnerSettingsResponse:
|
||||
return PartnerSettingsResponse(
|
||||
withdrawal_enabled=settings.REFERRAL_WITHDRAWAL_ENABLED,
|
||||
withdrawal_min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
|
||||
withdrawal_cooldown_days=settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS,
|
||||
withdrawal_requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
|
||||
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
|
||||
referral_program_enabled=settings.REFERRAL_PROGRAM_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/settings', response_model=PartnerSettingsResponse)
|
||||
async def get_partner_settings(
|
||||
admin: User = Depends(require_permission('partners:settings')),
|
||||
):
|
||||
"""Get partner system settings."""
|
||||
return _build_partner_settings_response()
|
||||
|
||||
|
||||
@router.patch('/settings', response_model=PartnerSettingsResponse)
|
||||
async def update_partner_settings(
|
||||
request: PartnerSettingsUpdateRequest,
|
||||
admin: User = Depends(require_permission('partners:settings')),
|
||||
):
|
||||
"""Update partner system settings."""
|
||||
from pathlib import Path
|
||||
|
||||
# Update in-memory settings
|
||||
if request.withdrawal_enabled is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_ENABLED = request.withdrawal_enabled
|
||||
if request.withdrawal_min_amount_kopeks is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS = request.withdrawal_min_amount_kopeks
|
||||
if request.withdrawal_cooldown_days is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_COOLDOWN_DAYS = request.withdrawal_cooldown_days
|
||||
if request.withdrawal_requisites_text is not None:
|
||||
settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT = request.withdrawal_requisites_text
|
||||
if request.partner_section_visible is not None:
|
||||
settings.REFERRAL_PARTNER_SECTION_VISIBLE = request.partner_section_visible
|
||||
if request.referral_program_enabled is not None:
|
||||
settings.REFERRAL_PROGRAM_ENABLED = request.referral_program_enabled
|
||||
|
||||
# Persist to .env file
|
||||
try:
|
||||
env_file = Path('.env')
|
||||
if env_file.exists():
|
||||
lines = env_file.read_text().splitlines()
|
||||
updates: dict[str, str] = {}
|
||||
|
||||
if request.withdrawal_enabled is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_ENABLED'] = str(request.withdrawal_enabled).lower()
|
||||
if request.withdrawal_min_amount_kopeks is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS'] = str(request.withdrawal_min_amount_kopeks)
|
||||
if request.withdrawal_cooldown_days is not None:
|
||||
updates['REFERRAL_WITHDRAWAL_COOLDOWN_DAYS'] = str(request.withdrawal_cooldown_days)
|
||||
if request.withdrawal_requisites_text is not None:
|
||||
# Sanitize: replace newlines to prevent .env injection
|
||||
sanitized = (
|
||||
request.withdrawal_requisites_text.replace('\r\n', ' ').replace('\n', ' ').replace('\r', ' ')
|
||||
)
|
||||
updates['REFERRAL_WITHDRAWAL_REQUISITES_TEXT'] = sanitized
|
||||
if request.partner_section_visible is not None:
|
||||
updates['REFERRAL_PARTNER_SECTION_VISIBLE'] = str(request.partner_section_visible).lower()
|
||||
if request.referral_program_enabled is not None:
|
||||
updates['REFERRAL_PROGRAM_ENABLED'] = str(request.referral_program_enabled).lower()
|
||||
|
||||
new_lines = []
|
||||
updated_keys: set[str] = set()
|
||||
|
||||
for line in lines:
|
||||
updated = False
|
||||
for key, value in updates.items():
|
||||
if line.startswith(f'{key}='):
|
||||
new_lines.append(f'{key}={value}')
|
||||
updated_keys.add(key)
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
new_lines.append(line)
|
||||
|
||||
for key, value in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f'{key}={value}')
|
||||
|
||||
env_file.write_text('\n'.join(new_lines) + '\n')
|
||||
logger.info('Updated partner settings in .env file', admin_id=admin.id)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to update .env file', error=e)
|
||||
|
||||
return _build_partner_settings_response()
|
||||
|
||||
|
||||
# ==================== Applications (static paths first) ====================
|
||||
|
||||
|
||||
@router.get('/applications', response_model=AdminPartnerApplicationsResponse)
|
||||
async def list_applications(
|
||||
application_status: Literal['pending', 'approved', 'rejected', 'none'] | None = Query(None, alias='status'),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List partner applications."""
|
||||
applications, total = await partner_application_service.get_all_applications(
|
||||
db, status=application_status, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
# Batch-fetch users to avoid N+1
|
||||
user_ids = list({app.user_id for app in applications})
|
||||
if user_ids:
|
||||
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
users_map = {u.id: u for u in users_result.scalars().all()}
|
||||
else:
|
||||
users_map = {}
|
||||
|
||||
items = []
|
||||
for app in applications:
|
||||
user = users_map.get(app.user_id)
|
||||
items.append(
|
||||
AdminPartnerApplicationItem(
|
||||
id=app.id,
|
||||
user_id=app.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
company_name=app.company_name,
|
||||
website_url=app.website_url,
|
||||
telegram_channel=app.telegram_channel,
|
||||
description=app.description,
|
||||
expected_monthly_referrals=app.expected_monthly_referrals,
|
||||
status=app.status,
|
||||
admin_comment=app.admin_comment,
|
||||
approved_commission_percent=app.approved_commission_percent,
|
||||
created_at=app.created_at,
|
||||
processed_at=app.processed_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminPartnerApplicationsResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.post('/applications/{application_id}/approve')
|
||||
async def approve_application(
|
||||
application_id: int,
|
||||
request: AdminApproveRequest,
|
||||
admin: User = Depends(require_permission('partners:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a partner application."""
|
||||
success, error = await partner_application_service.approve_application(
|
||||
db,
|
||||
application_id=application_id,
|
||||
admin_id=admin.id,
|
||||
commission_percent=request.commission_percent,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
application = await db.get(PartnerApplication, application_id)
|
||||
user = await db.get(User, application.user_id) if application else None
|
||||
if user:
|
||||
comment_text = f'\n{request.comment}' if request.comment else ''
|
||||
tg_message = (
|
||||
f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}'
|
||||
)
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_approved(
|
||||
user=user,
|
||||
commission_percent=request.commission_percent,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send partner approval notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/applications/{application_id}/reject')
|
||||
async def reject_application(
|
||||
application_id: int,
|
||||
request: AdminRejectRequest,
|
||||
admin: User = Depends(require_permission('partners:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a partner application."""
|
||||
success, error = await partner_application_service.reject_application(
|
||||
db,
|
||||
application_id=application_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
application = await db.get(PartnerApplication, application_id)
|
||||
user = await db.get(User, application.user_id) if application else None
|
||||
if user:
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_partner_rejected(
|
||||
user=user,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send partner rejection notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
# ==================== Stats (static paths) ====================
|
||||
|
||||
|
||||
@router.get('/stats')
|
||||
async def get_partner_stats(
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get overall partner statistics."""
|
||||
total_partners = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
)
|
||||
pending_apps = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(PartnerApplication)
|
||||
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
|
||||
)
|
||||
total_referrals = await db.execute(select(func.count()).select_from(User).where(User.referred_by_id.isnot(None)))
|
||||
total_earnings = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
|
||||
|
||||
return {
|
||||
'total_partners': total_partners.scalar() or 0,
|
||||
'pending_applications': pending_apps.scalar() or 0,
|
||||
'total_referrals': total_referrals.scalar() or 0,
|
||||
'total_earnings_kopeks': total_earnings.scalar() or 0,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Partners list ====================
|
||||
|
||||
|
||||
@router.get('', response_model=AdminPartnerListResponse)
|
||||
async def list_partners(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List approved partners."""
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.partner_status == PartnerStatus.APPROVED.value)
|
||||
.order_by(desc(User.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
partners = result.scalars().all()
|
||||
|
||||
# Batch-fetch earnings and referral counts to avoid N+1
|
||||
partner_ids = [u.id for u in partners]
|
||||
earnings_map: dict[int, int] = {}
|
||||
referral_count_map: dict[int, int] = {}
|
||||
|
||||
if partner_ids:
|
||||
earnings_result = await db.execute(
|
||||
select(ReferralEarning.user_id, func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
|
||||
.where(ReferralEarning.user_id.in_(partner_ids))
|
||||
.group_by(ReferralEarning.user_id)
|
||||
)
|
||||
earnings_map = {row[0]: int(row[1]) for row in earnings_result.all()}
|
||||
|
||||
referral_result = await db.execute(
|
||||
select(User.referred_by_id, func.count())
|
||||
.where(User.referred_by_id.in_(partner_ids))
|
||||
.group_by(User.referred_by_id)
|
||||
)
|
||||
referral_count_map = {row[0]: row[1] for row in referral_result.all()}
|
||||
|
||||
items = []
|
||||
for user in partners:
|
||||
items.append(
|
||||
AdminPartnerItem(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
telegram_id=user.telegram_id,
|
||||
commission_percent=user.referral_commission_percent,
|
||||
total_referrals=referral_count_map.get(user.id, 0),
|
||||
total_earnings_kopeks=earnings_map.get(user.id, 0),
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
partner_status=user.partner_status,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminPartnerListResponse(items=items, total=total)
|
||||
|
||||
|
||||
# ==================== Partner detail (parametric paths last) ====================
|
||||
|
||||
|
||||
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
|
||||
async def get_partner_detail(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed partner info."""
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Пользователь не найден',
|
||||
)
|
||||
|
||||
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
|
||||
|
||||
# Get assigned campaigns
|
||||
campaigns_result = await db.execute(
|
||||
select(AdvertisingCampaign).where(AdvertisingCampaign.partner_user_id == user_id)
|
||||
)
|
||||
campaigns = campaigns_result.scalars().all()
|
||||
campaign_list = [
|
||||
CampaignSummary(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
start_parameter=c.start_parameter,
|
||||
is_active=c.is_active,
|
||||
)
|
||||
for c in campaigns
|
||||
]
|
||||
|
||||
summary = stats['summary']
|
||||
earnings = stats['earnings']
|
||||
|
||||
return AdminPartnerDetailResponse(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
telegram_id=user.telegram_id,
|
||||
commission_percent=user.referral_commission_percent,
|
||||
partner_status=user.partner_status,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
total_referrals=summary['total_referrals'],
|
||||
paid_referrals=summary['paid_referrals'],
|
||||
active_referrals=summary['active_referrals'],
|
||||
earnings_all_time=earnings['all_time_kopeks'],
|
||||
earnings_today=earnings['today_kopeks'],
|
||||
earnings_week=earnings['week_kopeks'],
|
||||
earnings_month=earnings['month_kopeks'],
|
||||
conversion_to_paid=summary['conversion_to_paid_percent'],
|
||||
campaigns=campaign_list,
|
||||
created_at=user.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch('/{user_id}/commission')
|
||||
async def update_commission(
|
||||
user_id: int,
|
||||
request: AdminUpdateCommissionRequest,
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update partner commission percent."""
|
||||
user = await db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Пользователь не найден',
|
||||
)
|
||||
|
||||
if user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Пользователь не является партнёром',
|
||||
)
|
||||
|
||||
old_commission = user.referral_commission_percent
|
||||
user.referral_commission_percent = request.commission_percent
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Комиссия партнёра обновлена',
|
||||
user_id=user_id,
|
||||
old_commission=old_commission,
|
||||
new_commission=request.commission_percent,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
return {'success': True, 'commission_percent': request.commission_percent}
|
||||
|
||||
|
||||
@router.post('/{user_id}/revoke')
|
||||
async def revoke_partner(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_permission('partners:revoke')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke partner status."""
|
||||
success, error = await partner_application_service.revoke_partner(db, user_id=user_id, admin_id=admin.id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{user_id}/campaigns/{campaign_id}/assign')
|
||||
async def assign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Assign a campaign to a partner."""
|
||||
campaign = await db.get(AdvertisingCampaign, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Кампания не найдена',
|
||||
)
|
||||
|
||||
user = await db.get(User, user_id)
|
||||
if not user or user.partner_status != PartnerStatus.APPROVED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Пользователь не является партнёром',
|
||||
)
|
||||
|
||||
# Atomic check-and-set to prevent race conditions
|
||||
result = await db.execute(
|
||||
update(AdvertisingCampaign)
|
||||
.where(
|
||||
AdvertisingCampaign.id == campaign_id,
|
||||
or_(
|
||||
AdvertisingCampaign.partner_user_id.is_(None),
|
||||
AdvertisingCampaign.partner_user_id == user_id,
|
||||
),
|
||||
)
|
||||
.values(partner_user_id=user_id, updated_at=datetime.now(UTC))
|
||||
)
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Кампания уже привязана к другому партнёру',
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{user_id}/campaigns/{campaign_id}/unassign')
|
||||
async def unassign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unassign a campaign from a partner."""
|
||||
campaign = await db.get(AdvertisingCampaign, campaign_id)
|
||||
if not campaign:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Кампания не найдена',
|
||||
)
|
||||
|
||||
if campaign.partner_user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Кампания не привязана к этому партнёру',
|
||||
)
|
||||
|
||||
campaign.partner_user_id = None
|
||||
campaign.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
return {'success': True}
|
||||
@@ -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
|
||||
@@ -17,10 +17,10 @@ from app.services.payment_method_config_service import (
|
||||
update_sort_order,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/payment-methods', tags=['Cabinet Admin Payment Methods'])
|
||||
|
||||
@@ -124,7 +124,7 @@ def _enrich_config(config, defaults: dict) -> PaymentMethodConfigResponse:
|
||||
|
||||
@router.get('', response_model=list[PaymentMethodConfigResponse])
|
||||
async def list_payment_methods(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payment_methods:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all payment method configurations."""
|
||||
@@ -135,7 +135,7 @@ async def list_payment_methods(
|
||||
|
||||
@router.get('/promo-groups', response_model=list[PromoGroupSimple])
|
||||
async def list_promo_groups(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payment_methods:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all promo groups for filter selector."""
|
||||
@@ -146,7 +146,7 @@ async def list_promo_groups(
|
||||
@router.get('/{method_id}', response_model=PaymentMethodConfigResponse)
|
||||
async def get_payment_method(
|
||||
method_id: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payment_methods:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get a single payment method configuration."""
|
||||
@@ -163,12 +163,12 @@ async def get_payment_method(
|
||||
@router.put('/order')
|
||||
async def update_payment_methods_order(
|
||||
request: SortOrderRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payment_methods:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Batch update sort order for payment methods."""
|
||||
await update_sort_order(db, request.method_ids)
|
||||
logger.info(f'Admin {admin.id} updated payment methods order: {request.method_ids}')
|
||||
logger.info('Admin updated payment methods order', admin_id=admin.id, method_ids=request.method_ids)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ async def update_payment_methods_order(
|
||||
async def update_payment_method(
|
||||
method_id: str,
|
||||
request: PaymentMethodConfigUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payment_methods:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update a payment method configuration."""
|
||||
@@ -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
|
||||
@@ -19,10 +19,10 @@ from app.services.payment_verification_service import (
|
||||
run_manual_check,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/payments', tags=['Cabinet Admin Payments'])
|
||||
|
||||
@@ -272,7 +272,7 @@ async def get_all_pending_payments(
|
||||
page: int = Query(1, ge=1, description='Page number'),
|
||||
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
|
||||
method_filter: str | None = Query(None, description='Filter by payment method'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all pending payments for admin verification."""
|
||||
@@ -306,7 +306,7 @@ async def get_all_pending_payments(
|
||||
|
||||
@router.get('/stats', response_model=PaymentsStatsResponse)
|
||||
async def get_payments_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get statistics about pending payments."""
|
||||
@@ -329,7 +329,7 @@ async def get_payments_stats(
|
||||
async def get_pending_payment_details(
|
||||
method: str,
|
||||
payment_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payments:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get details of a specific pending payment."""
|
||||
@@ -356,7 +356,7 @@ async def get_pending_payment_details(
|
||||
async def check_payment_status(
|
||||
method: str,
|
||||
payment_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('payments:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Manually check and update payment status."""
|
||||
@@ -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 = 'Статус не изменился'
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin routes for pinned messages in cabinet."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
@@ -22,7 +22,7 @@ from app.services.pinned_message_service import (
|
||||
)
|
||||
from app.utils.validators import sanitize_html, validate_html_tags
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.pinned_messages import (
|
||||
PinnedMessageBroadcastResponse,
|
||||
PinnedMessageCreateRequest,
|
||||
@@ -34,7 +34,7 @@ from ..schemas.pinned_messages import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages'])
|
||||
|
||||
@@ -89,7 +89,7 @@ def _get_bot() -> Bot:
|
||||
|
||||
@router.get('', response_model=PinnedMessageListResponse)
|
||||
async def list_pinned_messages(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -117,7 +117,7 @@ async def list_pinned_messages(
|
||||
|
||||
@router.get('/active', response_model=PinnedMessageResponse | None)
|
||||
async def get_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Get current active pinned message."""
|
||||
@@ -130,7 +130,7 @@ async def get_active_message(
|
||||
@router.get('/{message_id}', response_model=PinnedMessageResponse)
|
||||
async def get_pinned_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Get pinned message by ID."""
|
||||
@@ -147,7 +147,7 @@ async def get_pinned_message(
|
||||
@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),
|
||||
admin: User = Depends(require_permission('pinned_messages:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
@@ -186,7 +186,9 @@ async def create_pinned_message(
|
||||
if payload.broadcast:
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(f'Admin {admin.id} created pinned message #{msg.id} (broadcast={payload.broadcast})')
|
||||
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),
|
||||
@@ -199,7 +201,7 @@ async def create_pinned_message(
|
||||
async def update_pinned_message(
|
||||
message_id: int,
|
||||
payload: PinnedMessageUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update a pinned message content, media, or settings."""
|
||||
@@ -225,11 +227,11 @@ async def update_pinned_message(
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.utcnow()
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
logger.info(f'Admin {admin.id} updated pinned message #{message_id}')
|
||||
logger.info('Admin updated pinned message #', admin_id=admin.id, message_id=message_id)
|
||||
|
||||
return _serialize_pinned_message(msg)
|
||||
|
||||
@@ -238,7 +240,7 @@ async def update_pinned_message(
|
||||
async def update_pinned_message_settings(
|
||||
message_id: int,
|
||||
payload: PinnedMessageSettingsRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse:
|
||||
"""Update only pinned message display settings."""
|
||||
@@ -253,7 +255,7 @@ async def update_pinned_message_settings(
|
||||
if payload.send_on_every_start is not None:
|
||||
msg.send_on_every_start = payload.send_on_every_start
|
||||
|
||||
msg.updated_at = datetime.utcnow()
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
@@ -265,7 +267,7 @@ async def update_pinned_message_settings(
|
||||
|
||||
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
|
||||
async def deactivate_active_message(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageResponse | None:
|
||||
"""Deactivate the current active pinned message without unpinning from users."""
|
||||
@@ -273,14 +275,14 @@ async def deactivate_active_message(
|
||||
if not msg:
|
||||
return None
|
||||
|
||||
logger.info(f'Admin {admin.id} deactivated pinned message #{msg.id}')
|
||||
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),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageUnpinResponse:
|
||||
"""Unpin messages from all users and deactivate the active pinned message."""
|
||||
@@ -288,7 +290,12 @@ async def unpin_active_message(
|
||||
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
|
||||
|
||||
if was_active:
|
||||
logger.info(f'Admin {admin.id} unpinned active message: unpinned={unpinned_count}, failed={failed_count}')
|
||||
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,
|
||||
@@ -304,7 +311,7 @@ async def unpin_active_message(
|
||||
async def activate_pinned_message(
|
||||
message_id: int,
|
||||
broadcast: bool = Query(False),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""
|
||||
@@ -325,11 +332,11 @@ async def activate_pinned_message(
|
||||
await db.execute(
|
||||
update(PinnedMessage)
|
||||
.where(PinnedMessage.is_active.is_(True))
|
||||
.values(is_active=False, updated_at=datetime.utcnow())
|
||||
.values(is_active=False, updated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
msg.is_active = True
|
||||
msg.updated_at = datetime.utcnow()
|
||||
msg.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
|
||||
@@ -339,7 +346,9 @@ async def activate_pinned_message(
|
||||
if broadcast:
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(f'Admin {admin.id} activated pinned message #{message_id} (broadcast={broadcast})')
|
||||
logger.info(
|
||||
'Admin activated pinned message # (broadcast=)', admin_id=admin.id, message_id=message_id, broadcast=broadcast
|
||||
)
|
||||
|
||||
return PinnedMessageBroadcastResponse(
|
||||
message=_serialize_pinned_message(msg),
|
||||
@@ -351,7 +360,7 @@ async def activate_pinned_message(
|
||||
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
|
||||
async def broadcast_message(
|
||||
message_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('pinned_messages:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PinnedMessageBroadcastResponse:
|
||||
"""Broadcast a pinned message to all active users."""
|
||||
@@ -364,7 +373,13 @@ async def broadcast_message(
|
||||
|
||||
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
|
||||
|
||||
logger.info(f'Admin {admin.id} broadcast pinned message #{message_id}: sent={sent_count}, failed={failed_count}')
|
||||
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),
|
||||
@@ -376,7 +391,7 @@ async def broadcast_message(
|
||||
@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),
|
||||
admin: User = Depends(require_permission('pinned_messages:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> None:
|
||||
"""Delete a pinned message. Active messages must be deactivated first."""
|
||||
@@ -394,4 +409,4 @@ async def delete_pinned_message(
|
||||
await db.delete(msg)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.id} deleted pinned message #{message_id}')
|
||||
logger.info('Admin deleted pinned message #', admin_id=admin.id, message_id=message_id)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Admin RBAC access policies management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AccessPolicyCRUD, AdminRoleCRUD
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/rbac/policies', tags=['Admin RBAC Policies'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class PolicyResponse(BaseModel):
|
||||
"""Access policy response."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
role_id: int | None = None
|
||||
role_name: str | None = None
|
||||
priority: int
|
||||
effect: str
|
||||
conditions: dict[str, Any] = Field(default_factory=dict)
|
||||
resource: str
|
||||
actions: list[str] = Field(default_factory=list)
|
||||
is_active: bool
|
||||
created_by: int | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class PolicyCreateRequest(BaseModel):
|
||||
"""Create a new access policy."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
role_id: int | None = None
|
||||
priority: int = Field(default=0, ge=0, le=1000)
|
||||
effect: str = Field(pattern=r'^(allow|deny)$')
|
||||
conditions: dict[str, Any] = Field(default_factory=dict)
|
||||
resource: str = Field(min_length=1, max_length=100)
|
||||
actions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PolicyUpdateRequest(BaseModel):
|
||||
"""Update policy fields (all optional)."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
role_id: int | None = None
|
||||
priority: int | None = Field(default=None, ge=0, le=1000)
|
||||
effect: str | None = Field(default=None, pattern=r'^(allow|deny)$')
|
||||
conditions: dict[str, Any] | None = None
|
||||
resource: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
actions: list[str] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
async def _policy_to_response(db: AsyncSession, policy) -> PolicyResponse:
|
||||
"""Convert AccessPolicy model to PolicyResponse with role name."""
|
||||
role_name = None
|
||||
if policy.role_id is not None:
|
||||
role = await AdminRoleCRUD.get_by_id(db, policy.role_id)
|
||||
if role:
|
||||
role_name = role.name
|
||||
|
||||
return PolicyResponse(
|
||||
id=policy.id,
|
||||
name=policy.name,
|
||||
description=policy.description,
|
||||
role_id=policy.role_id,
|
||||
role_name=role_name,
|
||||
priority=policy.priority,
|
||||
effect=policy.effect,
|
||||
conditions=policy.conditions or {},
|
||||
resource=policy.resource,
|
||||
actions=policy.actions or [],
|
||||
is_active=policy.is_active,
|
||||
created_by=policy.created_by,
|
||||
created_at=policy.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('', response_model=list[PolicyResponse])
|
||||
async def list_policies(
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
role_id: int | None = None,
|
||||
):
|
||||
"""List all access policies. Optionally filter by role_id."""
|
||||
policies = await AccessPolicyCRUD.get_all(db, role_id=role_id)
|
||||
return [await _policy_to_response(db, p) for p in policies]
|
||||
|
||||
|
||||
@router.post('', response_model=PolicyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_policy(
|
||||
payload: PolicyCreateRequest,
|
||||
admin: User = Depends(require_permission('roles:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new access policy (ABAC rule)."""
|
||||
# Validate role_id if provided
|
||||
if payload.role_id is not None:
|
||||
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Referenced role not found',
|
||||
)
|
||||
|
||||
policy = await AccessPolicyCRUD.create(
|
||||
db,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
role_id=payload.role_id,
|
||||
priority=payload.priority,
|
||||
effect=payload.effect,
|
||||
conditions=payload.conditions,
|
||||
resource=payload.resource,
|
||||
actions=payload.actions,
|
||||
created_by=admin.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin created access policy',
|
||||
admin_id=admin.id,
|
||||
policy_id=policy.id,
|
||||
policy_name=policy.name,
|
||||
effect=policy.effect,
|
||||
)
|
||||
return await _policy_to_response(db, policy)
|
||||
|
||||
|
||||
@router.put('/{policy_id}', response_model=PolicyResponse)
|
||||
async def update_policy(
|
||||
policy_id: int,
|
||||
payload: PolicyUpdateRequest,
|
||||
admin: User = Depends(require_permission('roles:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing access policy."""
|
||||
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Policy not found',
|
||||
)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate role_id if changing
|
||||
if 'role_id' in update_data and update_data['role_id'] is not None:
|
||||
role = await AdminRoleCRUD.get_by_id(db, update_data['role_id'])
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Referenced role not found',
|
||||
)
|
||||
|
||||
updated = await AccessPolicyCRUD.update(db, policy_id, **update_data)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Policy not found',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin updated access policy',
|
||||
admin_id=admin.id,
|
||||
policy_id=policy_id,
|
||||
fields=list(update_data.keys()),
|
||||
)
|
||||
return await _policy_to_response(db, updated)
|
||||
|
||||
|
||||
@router.delete('/{policy_id}')
|
||||
async def delete_policy(
|
||||
policy_id: int,
|
||||
admin: User = Depends(require_permission('roles:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete an access policy."""
|
||||
existing = await AccessPolicyCRUD.get_by_id(db, policy_id)
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Policy not found',
|
||||
)
|
||||
|
||||
deleted = await AccessPolicyCRUD.delete(db, policy_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to delete policy',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin deleted access policy',
|
||||
admin_id=admin.id,
|
||||
policy_id=policy_id,
|
||||
policy_name=existing.name,
|
||||
)
|
||||
return {'message': 'Policy deleted', 'policy_id': policy_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
|
||||
@@ -34,10 +34,10 @@ from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate
|
||||
from app.handlers.admin.messages import get_custom_users, get_target_users
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/promo-offers', tags=['Admin Promo Offers'])
|
||||
|
||||
@@ -272,7 +272,7 @@ async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
|
||||
|
||||
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
|
||||
async def list_templates(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateListResponse:
|
||||
"""Get list of promo offer templates."""
|
||||
@@ -288,7 +288,7 @@ async def list_templates(
|
||||
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
|
||||
async def get_template(
|
||||
template_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
"""Get a promo offer template."""
|
||||
@@ -302,7 +302,7 @@ async def get_template(
|
||||
async def update_template(
|
||||
template_id: int,
|
||||
payload: PromoOfferTemplateUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferTemplateResponse:
|
||||
"""Update a promo offer template."""
|
||||
@@ -338,7 +338,7 @@ async def update_template(
|
||||
|
||||
@router.get('', response_model=PromoOfferListResponse)
|
||||
async def list_offers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -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
|
||||
@@ -499,7 +491,7 @@ async def _send_promo_notifications(
|
||||
@router.post('/broadcast', response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def broadcast_offer(
|
||||
payload: PromoOfferBroadcastRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:send')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoOfferBroadcastResponse:
|
||||
"""Broadcast promo offer to users with optional Telegram notification."""
|
||||
@@ -613,7 +605,7 @@ async def broadcast_offer(
|
||||
|
||||
@router.get('/logs', response_model=PromoOfferLogListResponse)
|
||||
async def get_logs(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_offers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
|
||||
@@ -30,7 +30,7 @@ from app.database.crud.promocode import (
|
||||
)
|
||||
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse, PromoGroup, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
router = APIRouter(prefix='/admin/promocodes', tags=['Admin Promocodes'])
|
||||
@@ -162,9 +162,9 @@ def _normalize_datetime(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is not None and value.utcoffset() is not None:
|
||||
return value.astimezone(UTC).replace(tzinfo=None)
|
||||
return value.astimezone(UTC)
|
||||
if value.tzinfo is not None:
|
||||
return value.replace(tzinfo=None)
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
@@ -305,7 +305,7 @@ def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCo
|
||||
|
||||
@router.get('', response_model=PromoCodeListResponse)
|
||||
async def list_promocodes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -326,7 +326,7 @@ async def list_promocodes(
|
||||
@router.get('/{promocode_id}', response_model=PromoCodeDetailResponse)
|
||||
async def get_promocode(
|
||||
promocode_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeDetailResponse:
|
||||
"""Get promocode details with usage statistics."""
|
||||
@@ -349,7 +349,7 @@ async def get_promocode(
|
||||
@router.post('', response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promocode_endpoint(
|
||||
payload: PromoCodeCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeResponse:
|
||||
"""Create a new promocode."""
|
||||
@@ -399,7 +399,7 @@ async def create_promocode_endpoint(
|
||||
async def update_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
payload: PromoCodeUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoCodeResponse:
|
||||
"""Update an existing promocode."""
|
||||
@@ -460,7 +460,7 @@ async def update_promocode_endpoint(
|
||||
)
|
||||
async def delete_promocode_endpoint(
|
||||
promocode_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> Response:
|
||||
"""Delete a promocode."""
|
||||
@@ -486,7 +486,7 @@ class DeactivateDiscountResponse(BaseModel):
|
||||
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
|
||||
async def admin_deactivate_discount_promocode(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promocodes:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> DeactivateDiscountResponse:
|
||||
"""Admin: deactivate a user's active discount promo code."""
|
||||
@@ -537,7 +537,7 @@ promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo
|
||||
|
||||
@promo_groups_router.get('', response_model=PromoGroupListResponse)
|
||||
async def list_promo_groups(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_groups:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
@@ -561,7 +561,7 @@ async def list_promo_groups(
|
||||
@promo_groups_router.get('/{group_id}', response_model=PromoGroupResponse)
|
||||
async def get_promo_group(
|
||||
group_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_groups:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Get promo group details."""
|
||||
@@ -576,7 +576,7 @@ async def get_promo_group(
|
||||
@promo_groups_router.post('', response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_promo_group_endpoint(
|
||||
payload: PromoGroupCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_groups:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Create a new promo group."""
|
||||
@@ -608,7 +608,7 @@ async def create_promo_group_endpoint(
|
||||
async def update_promo_group_endpoint(
|
||||
group_id: int,
|
||||
payload: PromoGroupUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_groups:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromoGroupResponse:
|
||||
"""Update a promo group."""
|
||||
@@ -645,7 +645,7 @@ async def update_promo_group_endpoint(
|
||||
@promo_groups_router.delete('/{group_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_promo_group_endpoint(
|
||||
group_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('promo_groups:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> Response:
|
||||
"""Delete a promo group."""
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin routes for RemnaWave management in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.database.crud.server_squad import (
|
||||
from app.database.models import User
|
||||
from app.utils.cache import cache
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.remnawave import (
|
||||
AutoSyncRunResponse,
|
||||
# Auto Sync
|
||||
@@ -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'])
|
||||
|
||||
@@ -109,7 +109,10 @@ def _parse_datetime(value: Any) -> datetime | None:
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
@@ -150,7 +153,7 @@ def _serialize_node(node_data: dict[str, Any]) -> NodeInfo:
|
||||
|
||||
@router.get('/status', response_model=RemnaWaveStatusResponse)
|
||||
async def get_remnawave_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> RemnaWaveStatusResponse:
|
||||
"""Get RemnaWave configuration and connection status."""
|
||||
service = _get_service()
|
||||
@@ -173,7 +176,7 @@ async def get_remnawave_status(
|
||||
|
||||
@router.get('/system', response_model=SystemStatsResponse)
|
||||
async def get_system_statistics(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> SystemStatsResponse:
|
||||
"""Get full system statistics from RemnaWave."""
|
||||
service = _get_service()
|
||||
@@ -235,7 +238,7 @@ async def get_system_statistics(
|
||||
|
||||
@router.get('/nodes', response_model=NodesListResponse)
|
||||
async def list_nodes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> NodesListResponse:
|
||||
"""Get list of all nodes."""
|
||||
service = _get_service()
|
||||
@@ -249,7 +252,7 @@ async def list_nodes(
|
||||
|
||||
@router.get('/nodes/overview', response_model=NodesOverview)
|
||||
async def get_nodes_overview(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> NodesOverview:
|
||||
"""Get nodes overview with statistics."""
|
||||
service = _get_service()
|
||||
@@ -275,7 +278,7 @@ async def get_nodes_overview(
|
||||
|
||||
@router.get('/nodes/realtime')
|
||||
async def get_nodes_realtime(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get realtime node usage data."""
|
||||
service = _get_service()
|
||||
@@ -287,7 +290,7 @@ async def get_nodes_realtime(
|
||||
@router.get('/nodes/{node_uuid}', response_model=NodeInfo)
|
||||
async def get_node_details(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> NodeInfo:
|
||||
"""Get detailed information about a specific node."""
|
||||
service = _get_service()
|
||||
@@ -306,7 +309,7 @@ async def get_node_details(
|
||||
@router.get('/nodes/{node_uuid}/statistics', response_model=NodeStatisticsResponse)
|
||||
async def get_node_statistics(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> NodeStatisticsResponse:
|
||||
"""Get node statistics with usage history."""
|
||||
service = _get_service()
|
||||
@@ -332,13 +335,13 @@ async def get_node_usage(
|
||||
node_uuid: str,
|
||||
start: datetime | None = Query(default=None),
|
||||
end: datetime | None = Query(default=None),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> NodeUsageResponse:
|
||||
"""Get node usage history for a date range."""
|
||||
service = _get_service()
|
||||
_ensure_configured(service)
|
||||
|
||||
end_dt = end or datetime.utcnow()
|
||||
end_dt = end or datetime.now(UTC)
|
||||
start_dt = start or (end_dt - timedelta(days=7))
|
||||
|
||||
if start_dt >= end_dt:
|
||||
@@ -355,7 +358,7 @@ async def get_node_usage(
|
||||
async def perform_node_action(
|
||||
node_uuid: str,
|
||||
payload: NodeActionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> NodeActionResponse:
|
||||
"""Perform an action on a node (enable/disable/restart)."""
|
||||
service = _get_service()
|
||||
@@ -380,7 +383,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'),
|
||||
@@ -394,7 +399,7 @@ async def perform_node_action(
|
||||
|
||||
@router.post('/nodes/restart-all', response_model=NodeActionResponse)
|
||||
async def restart_all_nodes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> NodeActionResponse:
|
||||
"""Restart all nodes."""
|
||||
service = _get_service()
|
||||
@@ -403,7 +408,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,
|
||||
@@ -416,7 +421,7 @@ async def restart_all_nodes(
|
||||
|
||||
@router.get('/squads', response_model=SquadsListResponse)
|
||||
async def list_squads(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SquadsListResponse:
|
||||
"""Get list of all squads with local database info."""
|
||||
@@ -458,7 +463,7 @@ async def list_squads(
|
||||
@router.get('/squads/{squad_uuid}', response_model=SquadDetailResponse)
|
||||
async def get_squad_details(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SquadDetailResponse:
|
||||
"""Get detailed information about a squad."""
|
||||
@@ -501,7 +506,7 @@ async def get_squad_details(
|
||||
@router.post('/squads', response_model=SquadOperationResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_squad(
|
||||
payload: SquadCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> SquadOperationResponse:
|
||||
"""Create a new squad in RemnaWave."""
|
||||
service = _get_service()
|
||||
@@ -510,7 +515,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',
|
||||
@@ -526,7 +533,7 @@ async def create_squad(
|
||||
async def update_squad(
|
||||
squad_uuid: str,
|
||||
payload: SquadUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> SquadOperationResponse:
|
||||
"""Update a squad in RemnaWave."""
|
||||
service = _get_service()
|
||||
@@ -545,7 +552,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,
|
||||
@@ -557,7 +564,7 @@ async def update_squad(
|
||||
async def perform_squad_action(
|
||||
squad_uuid: str,
|
||||
payload: SquadActionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> SquadOperationResponse:
|
||||
"""Perform an action on a squad."""
|
||||
service = _get_service()
|
||||
@@ -594,7 +601,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)
|
||||
|
||||
@@ -602,7 +609,7 @@ async def perform_squad_action(
|
||||
@router.delete('/squads/{squad_uuid}', response_model=SquadOperationResponse)
|
||||
async def delete_squad(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
) -> SquadOperationResponse:
|
||||
"""Delete a squad."""
|
||||
service = _get_service()
|
||||
@@ -611,7 +618,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,
|
||||
@@ -625,7 +632,7 @@ async def delete_squad(
|
||||
@router.get('/squads/{squad_uuid}/migration-preview', response_model=MigrationPreviewResponse)
|
||||
async def preview_migration(
|
||||
squad_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MigrationPreviewResponse:
|
||||
"""Get migration preview for a squad."""
|
||||
@@ -650,7 +657,7 @@ async def preview_migration(
|
||||
@router.post('/squads/migrate', response_model=MigrationResponse)
|
||||
async def migrate_squad_users(
|
||||
payload: MigrationRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> MigrationResponse:
|
||||
"""Migrate users from one squad to another."""
|
||||
@@ -699,7 +706,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,
|
||||
@@ -722,7 +731,7 @@ async def migrate_squad_users(
|
||||
|
||||
@router.get('/inbounds', response_model=InboundsListResponse)
|
||||
async def list_inbounds(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> InboundsListResponse:
|
||||
"""Get list of all available inbounds."""
|
||||
service = _get_service()
|
||||
@@ -737,7 +746,7 @@ async def list_inbounds(
|
||||
|
||||
@router.get('/sync/auto/status', response_model=AutoSyncStatus)
|
||||
async def get_auto_sync_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
) -> AutoSyncStatus:
|
||||
"""Get auto sync status."""
|
||||
if remnawave_sync_service is None:
|
||||
@@ -766,7 +775,7 @@ async def get_auto_sync_status(
|
||||
@router.post('/sync/auto/toggle', response_model=SyncResponse)
|
||||
async def toggle_auto_sync(
|
||||
payload: AutoSyncToggleRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
) -> SyncResponse:
|
||||
"""Toggle auto sync on/off."""
|
||||
if remnawave_sync_service is None:
|
||||
@@ -782,14 +791,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.',
|
||||
@@ -802,7 +811,7 @@ async def toggle_auto_sync(
|
||||
|
||||
@router.post('/sync/auto/run', response_model=AutoSyncRunResponse)
|
||||
async def run_auto_sync_now(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
) -> AutoSyncRunResponse:
|
||||
"""Run auto sync immediately."""
|
||||
if remnawave_sync_service is None:
|
||||
@@ -811,7 +820,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(
|
||||
@@ -830,7 +839,7 @@ async def run_auto_sync_now(
|
||||
@router.post('/sync/from-panel', response_model=SyncResponse)
|
||||
async def sync_from_panel(
|
||||
payload: SyncMode,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync users from RemnaWave panel to bot."""
|
||||
@@ -839,7 +848,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',
|
||||
@@ -854,7 +863,7 @@ async def sync_from_panel(
|
||||
|
||||
@router.post('/sync/to-panel', response_model=SyncResponse)
|
||||
async def sync_to_panel(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync users from bot to RemnaWave panel."""
|
||||
@@ -862,7 +871,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,
|
||||
@@ -873,7 +882,7 @@ async def sync_to_panel(
|
||||
|
||||
@router.post('/sync/servers', response_model=SyncResponse)
|
||||
async def sync_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync servers/squads from RemnaWave."""
|
||||
@@ -892,9 +901,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,
|
||||
@@ -910,7 +925,7 @@ async def sync_servers(
|
||||
|
||||
@router.post('/sync/subscriptions/validate', response_model=SyncResponse)
|
||||
async def validate_subscriptions(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Validate and fix subscriptions."""
|
||||
@@ -918,7 +933,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,
|
||||
@@ -929,7 +944,7 @@ async def validate_subscriptions(
|
||||
|
||||
@router.post('/sync/subscriptions/cleanup', response_model=SyncResponse)
|
||||
async def cleanup_subscriptions(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Cleanup orphaned subscriptions."""
|
||||
@@ -937,7 +952,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,
|
||||
@@ -948,7 +963,7 @@ async def cleanup_subscriptions(
|
||||
|
||||
@router.post('/sync/subscriptions/statuses', response_model=SyncResponse)
|
||||
async def sync_subscription_statuses(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Sync subscription statuses."""
|
||||
@@ -956,7 +971,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,
|
||||
@@ -967,7 +982,7 @@ async def sync_subscription_statuses(
|
||||
|
||||
@router.get('/sync/recommendations', response_model=SyncResponse)
|
||||
async def get_sync_recommendations(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> SyncResponse:
|
||||
"""Get sync recommendations."""
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""Admin RBAC roles management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.rbac import AdminRoleCRUD, UserRoleCRUD
|
||||
from app.database.models import User
|
||||
from app.services.permission_service import PERMISSION_REGISTRY, get_all_permissions
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/rbac', tags=['Admin RBAC'])
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
|
||||
class RoleResponse(BaseModel):
|
||||
"""Admin role with user count."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
level: int
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
color: str | None = None
|
||||
icon: str | None = None
|
||||
is_system: bool
|
||||
is_active: bool
|
||||
user_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class RoleCreateRequest(BaseModel):
|
||||
"""Create a new custom role."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
level: int = Field(ge=0, le=998)
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
color: str | None = Field(default=None, max_length=7)
|
||||
icon: str | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
class RoleUpdateRequest(BaseModel):
|
||||
"""Update role fields (all optional)."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
description: str | None = None
|
||||
level: int | None = Field(default=None, ge=0, le=998)
|
||||
permissions: list[str] | None = None
|
||||
color: str | None = Field(default=None, max_length=7)
|
||||
icon: str | None = Field(default=None, max_length=50)
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class RoleAssignRequest(BaseModel):
|
||||
"""Assign a role to a user."""
|
||||
|
||||
user_id: int
|
||||
role_id: int
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class PermissionSection(BaseModel):
|
||||
"""Permission section with available actions."""
|
||||
|
||||
section: str
|
||||
actions: list[str]
|
||||
|
||||
|
||||
class UserRoleResponse(BaseModel):
|
||||
"""User-role assignment details."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
role_id: int
|
||||
role_name: str | None = None
|
||||
user_telegram_id: int | None = None
|
||||
user_username: str | None = None
|
||||
user_first_name: str | None = None
|
||||
user_email: str | None = None
|
||||
assigned_by: int | None = None
|
||||
assigned_at: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AdminWithRolesResponse(BaseModel):
|
||||
"""User that has at least one admin role."""
|
||||
|
||||
user_id: int
|
||||
telegram_id: int | None = None
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
email: str | None = None
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
async def _role_to_response(db: AsyncSession, role) -> RoleResponse:
|
||||
"""Convert AdminRole model to RoleResponse with user count."""
|
||||
user_count = await AdminRoleCRUD.count_users(db, role.id)
|
||||
return RoleResponse(
|
||||
id=role.id,
|
||||
name=role.name,
|
||||
description=role.description,
|
||||
level=role.level,
|
||||
permissions=role.permissions or [],
|
||||
color=role.color,
|
||||
icon=role.icon,
|
||||
is_system=role.is_system,
|
||||
is_active=role.is_active,
|
||||
user_count=user_count,
|
||||
created_at=role.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_admin_level(db: AsyncSession, admin: User) -> int:
|
||||
"""Get the maximum role level of the current admin.
|
||||
|
||||
Legacy config-based admins (ADMIN_IDS) get superadmin level (999+1=1000)
|
||||
so they can manage all roles including level 999.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
_perms, _names, max_level = await UserRoleCRUD.get_user_permissions(db, admin.id)
|
||||
|
||||
# Legacy config-based admins always get the highest level
|
||||
if settings.is_admin(
|
||||
telegram_id=admin.telegram_id,
|
||||
email=admin.email if admin.email_verified else None,
|
||||
):
|
||||
max_level = max(max_level, 1000)
|
||||
|
||||
return max_level
|
||||
|
||||
|
||||
def _validate_permissions(permissions: list[str]) -> None:
|
||||
"""Validate that all provided permissions exist in the registry."""
|
||||
all_valid = set(get_all_permissions())
|
||||
# Also allow wildcard patterns
|
||||
all_valid.add('*:*')
|
||||
for section in PERMISSION_REGISTRY:
|
||||
all_valid.add(f'{section}:*')
|
||||
|
||||
invalid = [p for p in permissions if p not in all_valid]
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid permissions: {", ".join(invalid)}',
|
||||
)
|
||||
|
||||
|
||||
# ============ Routes ============
|
||||
|
||||
|
||||
@router.get('/permissions', response_model=list[PermissionSection])
|
||||
async def get_permission_registry(
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
):
|
||||
"""Get all available permissions grouped by section."""
|
||||
return [
|
||||
PermissionSection(section=section, actions=list(actions)) for section, actions in PERMISSION_REGISTRY.items()
|
||||
]
|
||||
|
||||
|
||||
@router.get('/roles/{role_id}/users', response_model=list[UserRoleResponse])
|
||||
async def list_role_users(
|
||||
role_id: int,
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List user-role assignments for a specific role."""
|
||||
from sqlalchemy.orm import selectinload as _sel
|
||||
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Role not found')
|
||||
|
||||
from sqlalchemy import select as _sa_select
|
||||
|
||||
from app.database.models import UserRole as _UserRole
|
||||
|
||||
result = await db.execute(
|
||||
_sa_select(_UserRole)
|
||||
.options(_sel(_UserRole.user), _sel(_UserRole.role))
|
||||
.where(_UserRole.role_id == role_id, _UserRole.is_active.is_(True))
|
||||
.order_by(_UserRole.assigned_at.desc())
|
||||
)
|
||||
assignments = result.scalars().all()
|
||||
|
||||
return [
|
||||
UserRoleResponse(
|
||||
id=a.id,
|
||||
user_id=a.user_id,
|
||||
role_id=a.role_id,
|
||||
role_name=a.role.name if a.role else None,
|
||||
user_telegram_id=a.user.telegram_id if a.user else None,
|
||||
user_username=a.user.username if a.user else None,
|
||||
user_first_name=a.user.first_name if a.user else None,
|
||||
user_email=a.user.email if a.user else None,
|
||||
assigned_by=a.assigned_by,
|
||||
assigned_at=a.assigned_at,
|
||||
expires_at=a.expires_at,
|
||||
is_active=a.is_active,
|
||||
)
|
||||
for a in assignments
|
||||
]
|
||||
|
||||
|
||||
@router.get('/roles', response_model=list[RoleResponse])
|
||||
async def list_roles(
|
||||
admin: User = Depends(require_permission('roles:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""List all admin roles with user counts."""
|
||||
roles = await AdminRoleCRUD.get_all(db, include_inactive=include_inactive)
|
||||
return [await _role_to_response(db, role) for role in roles]
|
||||
|
||||
|
||||
@router.post('/roles', response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_role(
|
||||
payload: RoleCreateRequest,
|
||||
admin: User = Depends(require_permission('roles:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new custom admin role."""
|
||||
# Validate permissions list
|
||||
_validate_permissions(payload.permissions)
|
||||
|
||||
# Hierarchy enforcement: cannot create role with level >= own level
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
if payload.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot create a role with level >= your own role level',
|
||||
)
|
||||
|
||||
# Check name uniqueness
|
||||
existing = await AdminRoleCRUD.get_by_name(db, payload.name)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Role with this name already exists',
|
||||
)
|
||||
|
||||
role = await AdminRoleCRUD.create(
|
||||
db,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
level=payload.level,
|
||||
permissions=payload.permissions,
|
||||
color=payload.color,
|
||||
icon=payload.icon,
|
||||
created_by=admin.id,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin created role', admin_id=admin.id, role_id=role.id, role_name=role.name)
|
||||
return await _role_to_response(db, role)
|
||||
|
||||
|
||||
@router.put('/roles/{role_id}', response_model=RoleResponse)
|
||||
async def update_role(
|
||||
role_id: int,
|
||||
payload: RoleUpdateRequest,
|
||||
admin: User = Depends(require_permission('roles:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing admin role."""
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot edit a role at or above own level
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot edit a role at or above your own level',
|
||||
)
|
||||
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
# Validate level change
|
||||
if 'level' in update_data and update_data['level'] >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot set role level >= your own role level',
|
||||
)
|
||||
|
||||
# Validate permissions
|
||||
if 'permissions' in update_data and update_data['permissions'] is not None:
|
||||
_validate_permissions(update_data['permissions'])
|
||||
|
||||
# Check name uniqueness if name is changing
|
||||
if 'name' in update_data and update_data['name'] != role.name:
|
||||
existing = await AdminRoleCRUD.get_by_name(db, update_data['name'])
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Role with this name already exists',
|
||||
)
|
||||
|
||||
updated = await AdminRoleCRUD.update(db, role_id, **update_data)
|
||||
if not updated:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin updated role', admin_id=admin.id, role_id=role_id, fields=list(update_data.keys()))
|
||||
return await _role_to_response(db, updated)
|
||||
|
||||
|
||||
@router.delete('/roles/{role_id}')
|
||||
async def delete_role(
|
||||
role_id: int,
|
||||
admin: User = Depends(require_permission('roles:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a custom admin role. System roles cannot be deleted."""
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
if role.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot delete a system role',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot delete a role at or above your own level',
|
||||
)
|
||||
|
||||
deleted = await AdminRoleCRUD.delete(db, role_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to delete role',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info('Admin deleted role', admin_id=admin.id, role_id=role_id, role_name=role.name)
|
||||
return {'message': 'Role deleted', 'role_id': role_id}
|
||||
|
||||
|
||||
@router.post('/assignments', response_model=UserRoleResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def assign_role(
|
||||
payload: RoleAssignRequest,
|
||||
admin: User = Depends(require_permission('roles:assign')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Assign a role to a user. Hierarchy enforcement applies."""
|
||||
role = await AdminRoleCRUD.get_by_id(db, payload.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role not found',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot assign a role with level >= own level
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot assign a role with level >= your own role level',
|
||||
)
|
||||
|
||||
# Verify target user exists
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
target_user = await get_user_by_id(db, payload.user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Target user not found',
|
||||
)
|
||||
|
||||
user_role = await UserRoleCRUD.assign_role(
|
||||
db,
|
||||
user_id=payload.user_id,
|
||||
role_id=payload.role_id,
|
||||
assigned_by=admin.id,
|
||||
expires_at=payload.expires_at,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin assigned role',
|
||||
admin_id=admin.id,
|
||||
target_user_id=payload.user_id,
|
||||
role_id=payload.role_id,
|
||||
role_name=role.name,
|
||||
)
|
||||
return UserRoleResponse(
|
||||
id=user_role.id,
|
||||
user_id=user_role.user_id,
|
||||
role_id=user_role.role_id,
|
||||
role_name=role.name,
|
||||
user_telegram_id=target_user.telegram_id,
|
||||
user_username=target_user.username,
|
||||
user_first_name=target_user.first_name,
|
||||
user_email=target_user.email,
|
||||
assigned_by=user_role.assigned_by,
|
||||
assigned_at=user_role.assigned_at,
|
||||
expires_at=user_role.expires_at,
|
||||
is_active=user_role.is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/assignments/{assignment_id}')
|
||||
async def revoke_role(
|
||||
assignment_id: int,
|
||||
admin: User = Depends(require_permission('roles:assign')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke a role assignment. Cannot remove the last superadmin."""
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from app.database.models import UserRole
|
||||
|
||||
# Load the assignment to check hierarchy
|
||||
result = await db.execute(sa_select(UserRole).where(UserRole.id == assignment_id))
|
||||
user_role = result.scalar_one_or_none()
|
||||
if not user_role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Role assignment not found',
|
||||
)
|
||||
|
||||
role = await AdminRoleCRUD.get_by_id(db, user_role.role_id)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Associated role not found',
|
||||
)
|
||||
|
||||
admin_level = await _get_admin_level(db, admin)
|
||||
|
||||
# Cannot revoke a role at or above own level
|
||||
if role.level >= admin_level:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot revoke a role at or above your own level',
|
||||
)
|
||||
|
||||
# Protect last superadmin (level 999)
|
||||
superadmin_level = 999
|
||||
if role.level == superadmin_level:
|
||||
superadmin_count = await UserRoleCRUD.get_superadmin_count(db)
|
||||
if superadmin_count <= 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Cannot remove the last superadmin',
|
||||
)
|
||||
|
||||
revoked = await UserRoleCRUD.revoke_role(db, assignment_id)
|
||||
if not revoked:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Failed to revoke role',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin revoked role assignment',
|
||||
admin_id=admin.id,
|
||||
assignment_id=assignment_id,
|
||||
target_user_id=user_role.user_id,
|
||||
role_name=role.name,
|
||||
)
|
||||
return {'message': 'Role revoked', 'assignment_id': assignment_id}
|
||||
@@ -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
|
||||
@@ -17,7 +16,7 @@ from app.database.crud.server_squad import (
|
||||
from app.database.models import PromoGroup, ServerSquad, Subscription, Tariff, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.servers import (
|
||||
PromoGroupInfo,
|
||||
ServerDetailResponse,
|
||||
@@ -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'])
|
||||
|
||||
@@ -67,7 +66,7 @@ async def _get_tariffs_using_server(db: AsyncSession, squad_uuid: str) -> list[s
|
||||
@router.get('', response_model=ServerListResponse)
|
||||
async def list_servers(
|
||||
include_unavailable: bool = True,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all servers."""
|
||||
@@ -104,7 +103,7 @@ async def list_servers(
|
||||
@router.get('/{server_id}', response_model=ServerDetailResponse)
|
||||
async def get_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed server info."""
|
||||
@@ -147,7 +146,7 @@ async def get_server(
|
||||
async def update_existing_server(
|
||||
server_id: int,
|
||||
request: ServerUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing server."""
|
||||
@@ -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)
|
||||
|
||||
@@ -192,7 +191,7 @@ async def update_existing_server(
|
||||
@router.post('/{server_id}/toggle', response_model=ServerToggleResponse)
|
||||
async def toggle_server(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle server availability."""
|
||||
@@ -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,
|
||||
@@ -219,7 +218,7 @@ async def toggle_server(
|
||||
@router.post('/{server_id}/trial', response_model=ServerTrialToggleResponse)
|
||||
async def toggle_server_trial(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle server trial eligibility."""
|
||||
@@ -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,
|
||||
@@ -246,7 +245,7 @@ async def toggle_server_trial(
|
||||
@router.get('/{server_id}/stats', response_model=ServerStatsResponse)
|
||||
async def get_server_stats(
|
||||
server_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get server statistics."""
|
||||
@@ -288,7 +287,7 @@ async def get_server_stats(
|
||||
|
||||
@router.post('/sync', response_model=ServerSyncResponse)
|
||||
async def sync_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('servers:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Sync servers with RemnaWave."""
|
||||
@@ -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
|
||||
@@ -13,10 +13,10 @@ from app.services.system_settings_service import (
|
||||
bot_configuration_service,
|
||||
)
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/settings', tags=['Admin Settings'])
|
||||
|
||||
@@ -179,7 +179,7 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe
|
||||
|
||||
@router.get('/categories', response_model=list[SettingCategorySummary])
|
||||
async def list_categories(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
):
|
||||
"""Get list of setting categories."""
|
||||
categories = bot_configuration_service.get_categories()
|
||||
@@ -196,7 +196,7 @@ async def list_categories(
|
||||
|
||||
@router.get('', response_model=list[SettingDefinition])
|
||||
async def list_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
category: str | None = Query(default=None, alias='category_key'),
|
||||
):
|
||||
"""Get list of all settings or settings for a specific category."""
|
||||
@@ -217,7 +217,7 @@ async def list_settings(
|
||||
@router.get('/{key}', response_model=SettingDefinition)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:read')),
|
||||
):
|
||||
"""Get a specific setting by key."""
|
||||
try:
|
||||
@@ -232,7 +232,7 @@ async def get_setting(
|
||||
async def update_setting(
|
||||
key: str,
|
||||
payload: SettingUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update a setting value."""
|
||||
@@ -248,14 +248,14 @@ 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)
|
||||
|
||||
|
||||
@router.delete('/{key}', response_model=SettingDefinition)
|
||||
async def reset_setting(
|
||||
key: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset a setting to its default value."""
|
||||
@@ -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,10 +1,10 @@
|
||||
"""Admin routes for statistics dashboard in cabinet."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, func, select
|
||||
@@ -26,10 +26,10 @@ from app.database.models import (
|
||||
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
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_start_time = time.time()
|
||||
|
||||
@@ -246,7 +246,7 @@ class RecentPaymentsResponse(BaseModel):
|
||||
|
||||
@router.get('/dashboard', response_model=DashboardStats)
|
||||
async def get_dashboard_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get complete dashboard statistics for admin panel."""
|
||||
@@ -258,7 +258,7 @@ async def get_dashboard_stats(
|
||||
sub_stats = await get_subscriptions_statistics(db)
|
||||
|
||||
# Get financial statistics
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
trans_stats = await get_transactions_statistics(db, month_start, now)
|
||||
@@ -317,7 +317,7 @@ 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',
|
||||
@@ -326,7 +326,7 @@ async def get_dashboard_stats(
|
||||
|
||||
@router.get('/system-info', response_model=SystemInfoResponse)
|
||||
async def get_system_info(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get system information for admin dashboard."""
|
||||
@@ -349,7 +349,7 @@ async def get_system_info(
|
||||
subscriptions_active=subscriptions_active,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get system info: {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',
|
||||
@@ -358,13 +358,13 @@ async def get_system_info(
|
||||
|
||||
@router.get('/nodes', response_model=NodesOverview)
|
||||
async def get_nodes_status(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
):
|
||||
"""Get status of all nodes."""
|
||||
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',
|
||||
@@ -374,7 +374,7 @@ async def get_nodes_status(
|
||||
@router.post('/nodes/{node_uuid}/restart')
|
||||
async def restart_node(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
):
|
||||
"""Restart a node."""
|
||||
try:
|
||||
@@ -382,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,
|
||||
@@ -391,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',
|
||||
@@ -401,7 +401,7 @@ async def restart_node(
|
||||
@router.post('/nodes/{node_uuid}/toggle')
|
||||
async def toggle_node(
|
||||
node_uuid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('remnawave:manage')),
|
||||
):
|
||||
"""Enable or disable a node."""
|
||||
try:
|
||||
@@ -420,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,
|
||||
@@ -429,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',
|
||||
@@ -480,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,
|
||||
@@ -503,7 +503,7 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
logger.info('📊 Нет тарифов в системе, пропускаем статистику')
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
@@ -560,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(
|
||||
@@ -576,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,
|
||||
@@ -584,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
|
||||
|
||||
|
||||
@@ -594,12 +596,12 @@ async def _get_tariff_stats(db: AsyncSession) -> TariffStats | None:
|
||||
@router.get('/referrals/top', response_model=TopReferrersResponse)
|
||||
async def get_top_referrers(
|
||||
limit: int = 20,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get top referrers with earnings breakdown by period."""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
month_ago = now - timedelta(days=30)
|
||||
@@ -800,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 +812,7 @@ async def get_top_referrers(
|
||||
@router.get('/campaigns/top', response_model=TopCampaignsResponse)
|
||||
async def get_top_campaigns(
|
||||
limit: int = 20,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get top advertising campaigns with statistics."""
|
||||
@@ -857,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',
|
||||
@@ -867,12 +869,12 @@ async def get_top_campaigns(
|
||||
@router.get('/payments/recent', response_model=RecentPaymentsResponse)
|
||||
async def get_recent_payments(
|
||||
limit: int = 50,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('stats:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get recent payments with user info."""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
week_ago = now - timedelta(days=7)
|
||||
|
||||
@@ -996,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
|
||||
@@ -20,7 +19,7 @@ from app.database.crud.tariff import (
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tariffs import (
|
||||
PeriodPrice,
|
||||
PromoGroupInfo,
|
||||
@@ -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'])
|
||||
|
||||
@@ -108,7 +107,7 @@ def _period_prices_to_dict(period_prices: list[PeriodPrice]) -> dict:
|
||||
@router.get('', response_model=TariffListResponse)
|
||||
async def list_tariffs(
|
||||
include_inactive: bool = True,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all tariffs."""
|
||||
@@ -142,7 +141,7 @@ async def list_tariffs(
|
||||
|
||||
@router.get('/available-servers', response_model=list[ServerInfo])
|
||||
async def get_available_servers(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of all servers for tariff selection."""
|
||||
@@ -162,14 +161,14 @@ async def get_available_servers(
|
||||
@router.put('/order')
|
||||
async def update_tariff_order(
|
||||
request: TariffSortOrderRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the display order of tariffs."""
|
||||
await reorder_tariffs(db, request.tariff_ids)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f'Admin {admin.id} updated tariff order: {request.tariff_ids}')
|
||||
logger.info('Admin updated tariff order', admin_id=admin.id, tariff_ids=request.tariff_ids)
|
||||
|
||||
return {'message': 'Tariff order updated successfully'}
|
||||
|
||||
@@ -177,7 +176,7 @@ async def update_tariff_order(
|
||||
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
|
||||
async def get_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed tariff info."""
|
||||
@@ -247,7 +246,7 @@ async def get_tariff(
|
||||
@router.post('', response_model=TariffDetailResponse)
|
||||
async def create_new_tariff(
|
||||
request: TariffCreateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:create')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a new tariff."""
|
||||
@@ -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)
|
||||
@@ -308,7 +307,7 @@ async def create_new_tariff(
|
||||
async def update_existing_tariff(
|
||||
tariff_id: int,
|
||||
request: TariffUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update an existing tariff."""
|
||||
@@ -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)
|
||||
@@ -401,7 +400,7 @@ async def update_existing_tariff(
|
||||
@router.delete('/{tariff_id}')
|
||||
async def delete_existing_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a tariff."""
|
||||
@@ -414,7 +413,13 @@ async def delete_existing_tariff(
|
||||
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
await delete_tariff(db, tariff)
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})')
|
||||
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)
|
||||
@@ -425,7 +430,7 @@ async def delete_existing_tariff(
|
||||
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
|
||||
async def toggle_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle tariff active status."""
|
||||
@@ -440,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)
|
||||
@@ -455,7 +460,7 @@ async def toggle_tariff(
|
||||
@router.post('/{tariff_id}/trial', response_model=TariffTrialResponse)
|
||||
async def toggle_trial_tariff(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Toggle tariff trial availability.
|
||||
@@ -483,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,
|
||||
@@ -495,7 +500,7 @@ async def toggle_trial_tariff(
|
||||
@router.get('/{tariff_id}/stats', response_model=TariffStatsResponse)
|
||||
async def get_tariff_stats(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tariffs:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get tariff statistics."""
|
||||
@@ -530,7 +535,7 @@ async def get_tariff_stats(
|
||||
|
||||
# Calculate revenue from subscription payments for users on this tariff
|
||||
revenue_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.join(Subscription, Transaction.user_id == Subscription.user_id)
|
||||
.where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Admin tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import desc, func, select
|
||||
@@ -16,11 +16,11 @@ from app.database.crud.ticket import TicketCRUD
|
||||
from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import Ticket, TicketMessage, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tickets import TicketMessageResponse
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/tickets', tags=['Cabinet Admin Tickets'])
|
||||
|
||||
@@ -197,7 +197,7 @@ def _ticket_to_admin_response(ticket: Ticket, include_messages: bool = False) ->
|
||||
|
||||
@router.get('/stats', response_model=AdminStatsResponse)
|
||||
async def get_ticket_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket statistics."""
|
||||
@@ -222,7 +222,7 @@ async def get_ticket_stats(
|
||||
|
||||
@router.get('/settings', response_model=TicketSettingsResponse)
|
||||
async def get_ticket_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket system settings."""
|
||||
@@ -242,7 +242,7 @@ async def get_ticket_settings(
|
||||
@router.patch('/settings', response_model=TicketSettingsResponse)
|
||||
async def update_ticket_settings(
|
||||
request: TicketSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket system settings."""
|
||||
@@ -269,7 +269,7 @@ async def update_ticket_settings(
|
||||
if request.sla_reminder_cooldown_minutes is not None:
|
||||
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
|
||||
if request.support_system_mode is not None:
|
||||
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
|
||||
SupportSettingsService.set_system_mode(request.support_system_mode.strip().lower())
|
||||
|
||||
# Update cabinet notification settings
|
||||
if request.cabinet_user_notifications_enabled is not None:
|
||||
@@ -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,
|
||||
@@ -337,7 +337,7 @@ async def get_all_tickets(
|
||||
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
|
||||
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
|
||||
user_id: int | None = Query(None, description='Filter by user ID'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get all tickets for admin."""
|
||||
@@ -386,7 +386,7 @@ async def get_all_tickets(
|
||||
@router.get('/{ticket_id}', response_model=AdminTicketDetailResponse)
|
||||
async def get_ticket_detail(
|
||||
ticket_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket with all messages for admin."""
|
||||
@@ -428,7 +428,7 @@ async def get_ticket_detail(
|
||||
async def reply_to_ticket(
|
||||
ticket_id: int,
|
||||
request: AdminReplyRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:reply')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reply to a ticket as admin."""
|
||||
@@ -447,13 +447,13 @@ async def reply_to_ticket(
|
||||
user_id=ticket.user_id,
|
||||
message_text=request.message,
|
||||
is_from_admin=True,
|
||||
created_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status to answered
|
||||
ticket.status = 'answered'
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
@@ -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)
|
||||
|
||||
@@ -497,7 +497,7 @@ async def reply_to_ticket(
|
||||
async def update_ticket_status(
|
||||
ticket_id: int,
|
||||
request: AdminStatusUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:close')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket status."""
|
||||
@@ -522,9 +522,9 @@ async def update_ticket_status(
|
||||
)
|
||||
|
||||
ticket.status = request.status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
if request.status == 'closed':
|
||||
ticket.closed_at = datetime.utcnow()
|
||||
ticket.closed_at = datetime.now(UTC)
|
||||
else:
|
||||
ticket.closed_at = None
|
||||
|
||||
@@ -556,7 +556,7 @@ async def update_ticket_status(
|
||||
async def update_ticket_priority(
|
||||
ticket_id: int,
|
||||
request: AdminPriorityUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:close')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update ticket priority."""
|
||||
@@ -581,7 +581,7 @@ async def update_ticket_priority(
|
||||
)
|
||||
|
||||
ticket.priority = request.priority
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
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
|
||||
@@ -20,7 +20,7 @@ from app.config import settings
|
||||
from app.database.models import Subscription, Transaction, TransactionType, User
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.traffic import (
|
||||
ExportCsvRequest,
|
||||
ExportCsvResponse,
|
||||
@@ -32,7 +32,7 @@ from ..schemas.traffic import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
|
||||
|
||||
@@ -99,7 +99,13 @@ async def _aggregate_traffic(
|
||||
user_uuids_set = set(user_uuids)
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
nodes = await api.get_all_nodes()
|
||||
try:
|
||||
nodes = await api.get_all_nodes()
|
||||
except Exception:
|
||||
logger.warning('Failed to fetch nodes for traffic aggregation', exc_info=True)
|
||||
# Cache empty result to avoid hammering the failing API
|
||||
_traffic_cache[cache_key] = (now, {}, [])
|
||||
return {}, []
|
||||
|
||||
# Fetch per-node user stats — O(nodes) calls instead of O(users)
|
||||
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
|
||||
@@ -110,7 +116,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))
|
||||
@@ -256,7 +262,7 @@ def _build_traffic_items(
|
||||
|
||||
@router.get('', response_model=TrafficUsageResponse)
|
||||
async def get_traffic_usage(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('traffic:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
period: int = Query(30, ge=1, le=30),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
@@ -383,7 +389,7 @@ async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int,
|
||||
if not user_ids:
|
||||
return {}
|
||||
result = await db.execute(
|
||||
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
|
||||
select(Transaction.user_id, func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0))
|
||||
.where(
|
||||
and_(
|
||||
Transaction.user_id.in_(user_ids),
|
||||
@@ -491,7 +497,7 @@ async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict
|
||||
|
||||
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
|
||||
async def get_traffic_enrichment(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('traffic:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Return enrichment data: device counts, spending, dates, last node."""
|
||||
@@ -524,7 +530,7 @@ async def get_traffic_enrichment(
|
||||
@router.post('/export-csv', response_model=ExportCsvResponse)
|
||||
async def export_traffic_csv(
|
||||
request: ExportCsvRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('traffic:export')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
|
||||
@@ -685,7 +691,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.',
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""Admin routes for version and release information."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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
|
||||
from ..dependencies import require_permission
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
|
||||
|
||||
@@ -53,7 +53,7 @@ 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):
|
||||
if datetime.now(UTC) - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
|
||||
return _cabinet_cache['releases']
|
||||
|
||||
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
|
||||
@@ -75,16 +75,16 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
|
||||
}
|
||||
)
|
||||
_cabinet_cache['releases'] = releases
|
||||
_cabinet_last_check = datetime.now()
|
||||
logger.info('Fetched %d cabinet releases from GitHub', len(releases))
|
||||
_cabinet_last_check = datetime.now(UTC)
|
||||
logger.info('Fetched cabinet releases from GitHub', releases_count=len(releases))
|
||||
return releases
|
||||
logger.warning('GitHub API returned status %d for cabinet releases', response.status)
|
||||
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: %s', e)
|
||||
logger.error('Error fetching cabinet releases', e=e)
|
||||
return _cabinet_cache.get('releases', [])
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
|
||||
|
||||
@router.get('/releases', response_model=ReleasesResponse)
|
||||
async def get_releases(
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
current_user: User = Depends(require_permission('updates:read')),
|
||||
) -> ReleasesResponse:
|
||||
"""Get release information for bot and cabinet."""
|
||||
# Bot releases
|
||||
|
||||
+214
-155
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,14 @@
|
||||
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
|
||||
|
||||
from app.cabinet.dependencies import get_cabinet_db, get_current_admin_user
|
||||
from app.cabinet.dependencies import get_cabinet_db, require_permission
|
||||
from app.cabinet.schemas.wheel import (
|
||||
AdminSpinItem,
|
||||
AdminSpinsResponse,
|
||||
@@ -35,14 +35,14 @@ 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'])
|
||||
|
||||
|
||||
@router.get('/config', response_model=AdminWheelConfigResponse)
|
||||
async def get_admin_wheel_config(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить полную конфигурацию колеса."""
|
||||
@@ -93,7 +93,7 @@ async def get_admin_wheel_config(
|
||||
@router.put('/config', response_model=AdminWheelConfigResponse)
|
||||
async def update_admin_wheel_config(
|
||||
request: UpdateWheelConfigRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Обновить конфигурацию колеса."""
|
||||
@@ -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)
|
||||
@@ -155,7 +155,7 @@ async def update_admin_wheel_config(
|
||||
|
||||
@router.get('/prizes', response_model=list[WheelPrizeAdminResponse])
|
||||
async def get_prizes(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить список призов."""
|
||||
@@ -188,7 +188,7 @@ async def get_prizes(
|
||||
@router.post('/prizes', response_model=WheelPrizeAdminResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_prize(
|
||||
request: CreatePrizeRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Создать новый приз."""
|
||||
@@ -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,
|
||||
@@ -237,7 +237,7 @@ async def create_prize(
|
||||
async def update_prize(
|
||||
prize_id: int,
|
||||
request: UpdatePrizeRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Обновить приз."""
|
||||
@@ -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,
|
||||
@@ -286,7 +286,7 @@ async def update_prize(
|
||||
@router.delete('/prizes/{prize_id}', status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_prize_endpoint(
|
||||
prize_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Удалить приз."""
|
||||
@@ -298,18 +298,18 @@ 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)
|
||||
async def reorder_prizes(
|
||||
request: ReorderPrizesRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Переупорядочить призы."""
|
||||
await reorder_wheel_prizes(db, request.prize_ids)
|
||||
logger.info(f'🔄 Admin {admin.telegram_id} reordered prizes: {request.prize_ids}')
|
||||
logger.info('🔄 Admin reordered prizes', telegram_id=admin.telegram_id, prize_ids=request.prize_ids)
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@@ -317,7 +317,7 @@ async def reorder_prizes(
|
||||
async def get_statistics(
|
||||
date_from: datetime | None = Query(None),
|
||||
date_to: datetime | None = Query(None),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить статистику колеса."""
|
||||
@@ -344,7 +344,7 @@ async def get_all_spins_endpoint(
|
||||
date_to: datetime | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('wheel:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Получить все спины с фильтрами."""
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Admin routes for managing withdrawal requests in cabinet."""
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import (
|
||||
ReferralEarning,
|
||||
User,
|
||||
WithdrawalRequest,
|
||||
WithdrawalRequestStatus,
|
||||
)
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.withdrawals import (
|
||||
AdminApproveWithdrawalRequest,
|
||||
AdminRejectWithdrawalRequest,
|
||||
AdminWithdrawalDetailResponse,
|
||||
AdminWithdrawalItem,
|
||||
AdminWithdrawalListResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/admin/withdrawals', tags=['Cabinet Admin Withdrawals'])
|
||||
|
||||
|
||||
def _get_risk_level(risk_score: int) -> str:
|
||||
"""Get risk level from score."""
|
||||
if risk_score >= 70:
|
||||
return 'critical'
|
||||
if risk_score >= 50:
|
||||
return 'high'
|
||||
if risk_score >= 30:
|
||||
return 'medium'
|
||||
return 'low'
|
||||
|
||||
|
||||
@router.get('', response_model=AdminWithdrawalListResponse)
|
||||
async def list_withdrawals(
|
||||
withdrawal_status: Literal['pending', 'approved', 'rejected', 'completed', 'cancelled'] | None = Query(
|
||||
None, alias='status'
|
||||
),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
admin: User = Depends(require_permission('withdrawals:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all withdrawal requests."""
|
||||
query = select(WithdrawalRequest)
|
||||
count_query = select(func.count()).select_from(WithdrawalRequest)
|
||||
|
||||
if withdrawal_status:
|
||||
query = query.where(WithdrawalRequest.status == withdrawal_status)
|
||||
count_query = count_query.where(WithdrawalRequest.status == withdrawal_status)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Pending stats
|
||||
pending_count_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(WithdrawalRequest)
|
||||
.where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value)
|
||||
)
|
||||
pending_count = pending_count_result.scalar() or 0
|
||||
|
||||
pending_total_result = await db.execute(
|
||||
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
|
||||
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value
|
||||
)
|
||||
)
|
||||
pending_total = pending_total_result.scalar() or 0
|
||||
|
||||
query = query.order_by(desc(WithdrawalRequest.created_at)).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
withdrawals = result.scalars().all()
|
||||
|
||||
# Batch-fetch users to avoid N+1
|
||||
user_ids = list({w.user_id for w in withdrawals})
|
||||
if user_ids:
|
||||
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
users_map = {u.id: u for u in users_result.scalars().all()}
|
||||
else:
|
||||
users_map = {}
|
||||
|
||||
items = []
|
||||
for w in withdrawals:
|
||||
user = users_map.get(w.user_id)
|
||||
items.append(
|
||||
AdminWithdrawalItem(
|
||||
id=w.id,
|
||||
user_id=w.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
amount_kopeks=w.amount_kopeks,
|
||||
amount_rubles=w.amount_kopeks / 100,
|
||||
status=w.status,
|
||||
risk_score=w.risk_score or 0,
|
||||
risk_level=_get_risk_level(w.risk_score or 0),
|
||||
payment_details=w.payment_details,
|
||||
admin_comment=w.admin_comment,
|
||||
created_at=w.created_at,
|
||||
processed_at=w.processed_at,
|
||||
)
|
||||
)
|
||||
|
||||
return AdminWithdrawalListResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
pending_count=pending_count,
|
||||
pending_total_kopeks=pending_total,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
|
||||
async def get_withdrawal_detail(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(require_permission('withdrawals:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed withdrawal request with risk analysis."""
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
if not withdrawal:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Заявка не найдена',
|
||||
)
|
||||
|
||||
user = await db.get(User, withdrawal.user_id)
|
||||
|
||||
# Parse risk analysis
|
||||
risk_analysis = None
|
||||
if withdrawal.risk_analysis:
|
||||
try:
|
||||
risk_analysis = json.loads(withdrawal.risk_analysis)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Get referral stats
|
||||
referral_count = await db.execute(
|
||||
select(func.count()).select_from(User).where(User.referred_by_id == withdrawal.user_id)
|
||||
)
|
||||
total_earnings = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
|
||||
ReferralEarning.user_id == withdrawal.user_id
|
||||
)
|
||||
)
|
||||
|
||||
return AdminWithdrawalDetailResponse(
|
||||
id=withdrawal.id,
|
||||
user_id=withdrawal.user_id,
|
||||
username=user.username if user else None,
|
||||
first_name=user.first_name if user else None,
|
||||
telegram_id=user.telegram_id if user else None,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
amount_rubles=withdrawal.amount_kopeks / 100,
|
||||
status=withdrawal.status,
|
||||
risk_score=withdrawal.risk_score or 0,
|
||||
risk_level=_get_risk_level(withdrawal.risk_score or 0),
|
||||
risk_analysis=risk_analysis,
|
||||
payment_details=withdrawal.payment_details,
|
||||
admin_comment=withdrawal.admin_comment,
|
||||
balance_kopeks=user.balance_kopeks if user else 0,
|
||||
total_referrals=referral_count.scalar() or 0,
|
||||
total_earnings_kopeks=total_earnings.scalar() or 0,
|
||||
created_at=withdrawal.created_at,
|
||||
processed_at=withdrawal.processed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/approve')
|
||||
async def approve_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminApproveWithdrawalRequest,
|
||||
admin: User = Depends(require_permission('withdrawals:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a withdrawal request."""
|
||||
success, error = await referral_withdrawal_service.approve_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Notify user about approval
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
user = await db.get(User, withdrawal.user_id) if withdrawal else None
|
||||
if user and withdrawal:
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\n{request.comment}' if request.comment else ''
|
||||
tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_approved(
|
||||
user=user,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send withdrawal approval notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/reject')
|
||||
async def reject_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminRejectWithdrawalRequest,
|
||||
admin: User = Depends(require_permission('withdrawals:reject')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a withdrawal request."""
|
||||
success, error = await referral_withdrawal_service.reject_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error or 'Не удалось отклонить заявку',
|
||||
)
|
||||
|
||||
# Notify user about rejection
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.config import settings
|
||||
from app.services.notification_delivery_service import notification_delivery_service
|
||||
|
||||
if settings.BOT_TOKEN:
|
||||
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
|
||||
user = await db.get(User, withdrawal.user_id) if withdrawal else None
|
||||
if user and withdrawal:
|
||||
formatted_amount = settings.format_price(withdrawal.amount_kopeks)
|
||||
comment_text = f'\nПричина: {request.comment}' if request.comment else ''
|
||||
tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}'
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
await notification_delivery_service.notify_withdrawal_rejected(
|
||||
user=user,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
comment=request.comment,
|
||||
bot=bot,
|
||||
telegram_message=tg_message,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send withdrawal rejection notification', error=e)
|
||||
|
||||
return {'success': True}
|
||||
|
||||
|
||||
@router.post('/{withdrawal_id}/complete')
|
||||
async def complete_withdrawal(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(require_permission('withdrawals:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark a withdrawal as completed (money transferred)."""
|
||||
success, error = await referral_withdrawal_service.complete_request(
|
||||
db,
|
||||
request_id=withdrawal_id,
|
||||
admin_id=admin.id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error or 'Не удалось завершить заявку',
|
||||
)
|
||||
|
||||
return {'success': True}
|
||||
+253
-51
@@ -2,15 +2,20 @@
|
||||
|
||||
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
|
||||
from app.database.crud.campaign import (
|
||||
get_campaign_by_start_parameter,
|
||||
get_campaign_registration_by_user,
|
||||
)
|
||||
from app.database.crud.rbac import UserRoleCRUD
|
||||
from app.database.crud.user import (
|
||||
clear_email_change_pending,
|
||||
create_user,
|
||||
@@ -23,9 +28,10 @@ from app.database.crud.user import (
|
||||
verify_and_apply_email_change,
|
||||
)
|
||||
from app.database.models import CabinetRefreshToken, User
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.disposable_email_service import disposable_email_service
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
from ..auth import (
|
||||
create_access_token,
|
||||
@@ -49,6 +55,7 @@ from ..auth.jwt_handler import get_refresh_token_expires_at
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.auth import (
|
||||
AuthResponse,
|
||||
CampaignBonusInfo,
|
||||
EmailChangeRequest,
|
||||
EmailChangeResponse,
|
||||
EmailChangeVerifyRequest,
|
||||
@@ -69,7 +76,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'])
|
||||
|
||||
@@ -93,9 +100,17 @@ def _user_to_response(user: User) -> UserResponse:
|
||||
)
|
||||
|
||||
|
||||
def _create_auth_response(user: User) -> AuthResponse:
|
||||
"""Create full auth response with tokens."""
|
||||
access_token = create_access_token(user.id, user.telegram_id)
|
||||
async def _create_auth_response(user: User, db: AsyncSession) -> AuthResponse:
|
||||
"""Create full auth response with tokens and RBAC permissions."""
|
||||
user_permissions, user_role_names, user_role_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
||||
|
||||
access_token = create_access_token(
|
||||
user.id,
|
||||
user.telegram_id,
|
||||
permissions=user_permissions,
|
||||
roles=user_role_names,
|
||||
role_level=user_role_level,
|
||||
)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
|
||||
|
||||
@@ -118,12 +133,6 @@ async def _store_refresh_token(
|
||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||
expires_at = get_refresh_token_expires_at()
|
||||
|
||||
# Check if token already exists (handles race conditions)
|
||||
existing = await db.execute(select(CabinetRefreshToken).where(CabinetRefreshToken.token_hash == token_hash))
|
||||
if existing.scalar_one_or_none():
|
||||
# Token already stored, skip
|
||||
return
|
||||
|
||||
token_record = CabinetRefreshToken(
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
@@ -133,9 +142,104 @@ async def _store_refresh_token(
|
||||
db.add(token_record)
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception:
|
||||
# Handle race condition if token was inserted between check and insert
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
logger.debug('Refresh token already exists (duplicate)', user_id=user_id)
|
||||
|
||||
|
||||
async def _process_campaign_bonus(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
campaign_slug: str | None,
|
||||
) -> CampaignBonusInfo | None:
|
||||
"""Process campaign bonus for user during auth. Never raises."""
|
||||
if not campaign_slug:
|
||||
return None
|
||||
try:
|
||||
campaign = await get_campaign_by_start_parameter(db, campaign_slug, only_active=True)
|
||||
if not campaign:
|
||||
return None
|
||||
|
||||
# Skip if user IS the campaign partner — prevent self-referral
|
||||
if campaign.partner_user_id and campaign.partner_user_id == user.id:
|
||||
logger.debug(
|
||||
'Skipping campaign attribution: user is the campaign partner',
|
||||
user_id=user.id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
return None
|
||||
|
||||
# Lock user row to prevent concurrent bonus application (race condition)
|
||||
await db.execute(select(User).where(User.id == user.id).with_for_update())
|
||||
|
||||
existing = await get_campaign_registration_by_user(db, user.id)
|
||||
if existing:
|
||||
logger.debug('User already has campaign registration', user_id=user.id)
|
||||
return None
|
||||
|
||||
# Привязать реферала к партнёру кампании (если партнёр назначен и юзер ещё не привязан)
|
||||
if campaign.partner_user_id and not user.referred_by_id:
|
||||
user.referred_by_id = campaign.partner_user_id
|
||||
await db.flush()
|
||||
try:
|
||||
await process_referral_registration(db, user.id, campaign.partner_user_id, bot=None)
|
||||
logger.info(
|
||||
'Referral set from campaign partner',
|
||||
user_id=user.id,
|
||||
partner_user_id=campaign.partner_user_id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral from campaign partner', error=e)
|
||||
|
||||
service = AdvertisingCampaignService()
|
||||
result = await service.apply_campaign_bonus(db, user, campaign)
|
||||
if not result.success:
|
||||
return None
|
||||
|
||||
# Refresh user to get updated balance after bonus
|
||||
await db.refresh(user)
|
||||
|
||||
return CampaignBonusInfo(
|
||||
campaign_name=campaign.name,
|
||||
bonus_type=result.bonus_type or campaign.bonus_type,
|
||||
balance_kopeks=result.balance_kopeks,
|
||||
subscription_days=result.subscription_days,
|
||||
tariff_name=result.tariff_name,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to process campaign bonus', user_id=user.id, campaign_slug=campaign_slug)
|
||||
try:
|
||||
await db.rollback()
|
||||
# Re-fetch user so session stays usable for the caller
|
||||
await db.refresh(user)
|
||||
except Exception:
|
||||
logger.exception('Failed to rollback after campaign bonus error', user_id=user.id)
|
||||
return None
|
||||
|
||||
|
||||
async def _process_referral_code(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
referral_code: str | None,
|
||||
) -> None:
|
||||
"""Set referred_by_id for user if referral_code is valid. Never raises."""
|
||||
if not referral_code or user.referred_by_id:
|
||||
return
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, referral_code)
|
||||
if not referrer:
|
||||
return
|
||||
if referrer.id == user.id:
|
||||
return
|
||||
if referrer.email and user.email and referrer.email.lower() == user.email.lower():
|
||||
return
|
||||
user.referred_by_id = referrer.id
|
||||
await db.flush()
|
||||
await process_referral_registration(db, user.id, referrer.id, bot=None)
|
||||
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
|
||||
except Exception as e:
|
||||
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
|
||||
|
||||
|
||||
async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -> None:
|
||||
@@ -158,12 +262,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
|
||||
@@ -175,7 +279,7 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
existing_sub = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Parse panel data — panel returns local time with misleading +00:00 offset
|
||||
expire_at = panel_datetime_to_naive_utc(panel_user.expire_at)
|
||||
expire_at = panel_datetime_to_utc(panel_user.expire_at)
|
||||
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
|
||||
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
|
||||
|
||||
@@ -186,7 +290,7 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
device_limit = panel_user.hwid_device_limit or 1
|
||||
|
||||
# Determine status — expire_at is now naive UTC
|
||||
current_time = datetime.now(UTC).replace(tzinfo=None)
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
|
||||
sub_status = SubscriptionStatus.ACTIVE
|
||||
@@ -208,7 +312,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)
|
||||
@@ -228,13 +335,16 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
)
|
||||
db.add(new_sub)
|
||||
logger.info(
|
||||
f'Created subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
|
||||
'Created subscription for email user squads: devices',
|
||||
email=user.email,
|
||||
connected_squads=connected_squads,
|
||||
device_limit=device_limit,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to sync subscription from panel for {user.email}: {e}')
|
||||
logger.warning('Failed to sync subscription from panel for', email=user.email, error=e)
|
||||
# Don't rollback - it detaches user object and breaks subsequent operations
|
||||
# The sync is non-critical, main verification already succeeded
|
||||
|
||||
@@ -273,9 +383,19 @@ async def auth_telegram(
|
||||
tg_last_name = user_data.get('last_name')
|
||||
tg_language = user_data.get('language_code', 'ru')
|
||||
|
||||
# Resolve referral code to referrer ID for new users
|
||||
referrer_id = None
|
||||
if request.referral_code and not user:
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
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,8 +403,9 @@ async def auth_telegram(
|
||||
first_name=tg_first_name,
|
||||
last_name=tg_last_name,
|
||||
language=tg_language,
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
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
|
||||
@@ -298,7 +419,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(
|
||||
@@ -307,14 +428,22 @@ async def auth_telegram(
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
response = await _create_auth_response(user, db)
|
||||
|
||||
# Store refresh token
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -329,7 +458,7 @@ async def auth_telegram_widget(
|
||||
This endpoint validates data from Telegram Login Widget and returns
|
||||
JWT tokens for authenticated access.
|
||||
"""
|
||||
widget_data = request.model_dump()
|
||||
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
raise HTTPException(
|
||||
@@ -339,9 +468,21 @@ async def auth_telegram_widget(
|
||||
|
||||
user = await get_user_by_telegram_id(db, request.id)
|
||||
|
||||
# Resolve referral code to referrer ID for new users
|
||||
referrer_id = None
|
||||
if request.referral_code and not user:
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code', referral_code=request.referral_code, error=e)
|
||||
|
||||
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,8 +490,9 @@ async def auth_telegram_widget(
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
language='ru',
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
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(
|
||||
@@ -366,12 +508,20 @@ async def auth_telegram_widget(
|
||||
if request.last_name != user.last_name:
|
||||
user.last_name = request.last_name
|
||||
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
await _process_referral_code(db, user, request.referral_code)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -485,7 +635,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):
|
||||
@@ -512,11 +662,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,
|
||||
)
|
||||
|
||||
# Создать пользователя
|
||||
@@ -532,9 +688,9 @@ async def register_email_standalone(
|
||||
# Для тестового email - автоматически верифицировать
|
||||
if is_test_email:
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
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()
|
||||
@@ -579,9 +735,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)
|
||||
@@ -617,10 +775,10 @@ async def verify_email(
|
||||
|
||||
# Mark email as verified
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
user.email_verification_token = None
|
||||
user.email_verification_expires = None
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -628,9 +786,14 @@ async def verify_email(
|
||||
await _sync_subscription_from_panel_by_email(db, user)
|
||||
|
||||
# Return auth tokens so user is logged in after verification
|
||||
response = _create_auth_response(user)
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -724,7 +887,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,
|
||||
@@ -734,7 +897,7 @@ async def login_email(
|
||||
language='ru',
|
||||
)
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -767,12 +930,17 @@ async def login_email(
|
||||
detail='User account is not active',
|
||||
)
|
||||
|
||||
user.cabinet_last_login = datetime.utcnow()
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
response = _create_auth_response(user)
|
||||
response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, response.refresh_token)
|
||||
|
||||
# Process campaign bonus
|
||||
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
|
||||
if response.campaign_bonus:
|
||||
response.user = _user_to_response(user)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -828,7 +996,14 @@ async def refresh_token(
|
||||
detail='User not found or inactive',
|
||||
)
|
||||
|
||||
access_token = create_access_token(user.id, user.telegram_id)
|
||||
user_permissions, user_role_names, user_role_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
||||
access_token = create_access_token(
|
||||
user.id,
|
||||
user.telegram_id,
|
||||
permissions=user_permissions,
|
||||
roles=user_role_names,
|
||||
role_level=user_role_level,
|
||||
)
|
||||
expires_in = settings.get_cabinet_access_token_expire_minutes() * 60
|
||||
|
||||
return TokenResponse(
|
||||
@@ -855,7 +1030,7 @@ async def logout(
|
||||
token_record = result.scalar_one_or_none()
|
||||
|
||||
if token_record:
|
||||
token_record.revoked_at = datetime.utcnow()
|
||||
token_record.revoked_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
return {'message': 'Logged out successfully'}
|
||||
@@ -952,12 +1127,32 @@ async def get_current_user(
|
||||
return _user_to_response(user)
|
||||
|
||||
|
||||
@router.get('/me/permissions')
|
||||
async def get_my_permissions(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get current user's RBAC permissions, roles, and level."""
|
||||
from app.services.permission_service import PermissionService
|
||||
|
||||
return await PermissionService.get_user_permissions(db, user.id, user=user)
|
||||
|
||||
|
||||
@router.get('/me/is-admin')
|
||||
async def check_is_admin(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Check if current user is an admin."""
|
||||
"""Check if current user is an admin (legacy config or RBAC)."""
|
||||
# Legacy check: config-based admin list
|
||||
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
|
||||
|
||||
if not is_admin:
|
||||
# RBAC check: user has any active role with level > 0
|
||||
_permissions, _role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
||||
if max_level > 0:
|
||||
is_admin = True
|
||||
|
||||
return {'is_admin': is_admin}
|
||||
|
||||
|
||||
@@ -1051,9 +1246,16 @@ async def request_email_change(
|
||||
custom_body_html=custom_body,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to send verification email to {request.new_email} for user {user.id}: {e}')
|
||||
logger.error(
|
||||
'Failed to send verification email to for user',
|
||||
new_email=request.new_email,
|
||||
user_id=user.id,
|
||||
error=e,
|
||||
)
|
||||
|
||||
logger.info(f'Unverified email replaced for user {user.id}: {old_email} -> {request.new_email}')
|
||||
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',
|
||||
@@ -1104,7 +1306,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,11 +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
|
||||
@@ -41,7 +41,7 @@ from ..schemas.balance import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/balance', tags=['Cabinet Balance'])
|
||||
|
||||
@@ -249,7 +249,7 @@ async def create_stars_invoice(
|
||||
if stars_amount <= 0:
|
||||
stars_amount = 1
|
||||
except Exception as e:
|
||||
logger.error(f'Error calculating Stars amount: {e}')
|
||||
logger.error('Error calculating Stars amount', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to calculate Stars amount',
|
||||
@@ -279,7 +279,7 @@ async def create_stars_invoice(
|
||||
result = response.json()
|
||||
|
||||
if not result.get('ok'):
|
||||
logger.error(f'Telegram API error: {result}')
|
||||
logger.error('Telegram API error', result=result)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create Stars invoice',
|
||||
@@ -287,8 +287,10 @@ async def create_stars_invoice(
|
||||
|
||||
invoice_url = result['result']
|
||||
logger.info(
|
||||
f'Created Stars invoice for balance top-up: user={user.id}, '
|
||||
f'amount={request.amount_kopeks} kopeks, stars={stars_amount}'
|
||||
'Created Stars invoice for balance top-up: user=, amount= kopeks, stars',
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
stars_amount=stars_amount,
|
||||
)
|
||||
|
||||
return StarsInvoiceResponse(
|
||||
@@ -298,7 +300,7 @@ async def create_stars_invoice(
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f'HTTP error creating Stars invoice: {e}')
|
||||
logger.error('HTTP error creating Stars invoice', error=e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to connect to Telegram API',
|
||||
@@ -710,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.',
|
||||
|
||||
+187
-26
@@ -1,23 +1,24 @@
|
||||
"""Branding routes for cabinet - logo, project name, and theme colors management."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import SystemSetting, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/branding', tags=['Branding'])
|
||||
|
||||
@@ -37,6 +38,17 @@ YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeri
|
||||
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"
|
||||
ANIMATION_CONFIG_KEY = 'CABINET_ANIMATION_CONFIG' # Stores JSON with animation config
|
||||
|
||||
# Default animation config
|
||||
DEFAULT_ANIMATION_CONFIG = {
|
||||
'enabled': True,
|
||||
'type': 'aurora',
|
||||
'settings': {},
|
||||
'opacity': 1.0,
|
||||
'blur': 0,
|
||||
'reducedOnMobile': True,
|
||||
}
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
|
||||
@@ -121,6 +133,92 @@ class AnimationEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
ALLOWED_BG_TYPES = (
|
||||
'aurora',
|
||||
'sparkles',
|
||||
'vortex',
|
||||
'shooting-stars',
|
||||
'background-beams',
|
||||
'background-beams-collision',
|
||||
'gradient-animation',
|
||||
'wavy',
|
||||
'background-lines',
|
||||
'boxes',
|
||||
'meteors',
|
||||
'grid',
|
||||
'dots',
|
||||
'spotlight',
|
||||
'ripple',
|
||||
'none',
|
||||
)
|
||||
|
||||
MAX_SETTINGS_KEYS = 20
|
||||
MAX_SETTINGS_VALUE_LEN = 200
|
||||
|
||||
|
||||
def _validate_settings(v: dict) -> dict:
|
||||
"""Validate settings dict: flat structure, bounded size, no nested objects."""
|
||||
if len(v) > MAX_SETTINGS_KEYS:
|
||||
raise ValueError(f'Settings must have at most {MAX_SETTINGS_KEYS} keys')
|
||||
for key, val in v.items():
|
||||
if not isinstance(key, str) or len(key) > 50:
|
||||
raise ValueError('Setting keys must be strings under 50 characters')
|
||||
if isinstance(val, dict | list):
|
||||
raise ValueError('Nested objects/arrays not allowed in settings')
|
||||
if isinstance(val, str) and len(val) > MAX_SETTINGS_VALUE_LEN:
|
||||
raise ValueError(f'String setting values must be under {MAX_SETTINGS_VALUE_LEN} characters')
|
||||
return v
|
||||
|
||||
|
||||
class AnimationConfigResponse(BaseModel):
|
||||
"""Full animation config."""
|
||||
|
||||
enabled: bool = True
|
||||
type: str = 'aurora'
|
||||
settings: dict = Field(default_factory=dict)
|
||||
opacity: float = Field(default=1.0, ge=0.0, le=1.0)
|
||||
blur: float = Field(default=0, ge=0, le=100)
|
||||
reducedOnMobile: bool = True
|
||||
|
||||
|
||||
class AnimationConfigUpdate(BaseModel):
|
||||
"""Request to update animation config (partial update)."""
|
||||
|
||||
enabled: bool | None = None
|
||||
type: (
|
||||
Literal[
|
||||
'aurora',
|
||||
'sparkles',
|
||||
'vortex',
|
||||
'shooting-stars',
|
||||
'background-beams',
|
||||
'background-beams-collision',
|
||||
'gradient-animation',
|
||||
'wavy',
|
||||
'background-lines',
|
||||
'boxes',
|
||||
'meteors',
|
||||
'grid',
|
||||
'dots',
|
||||
'spotlight',
|
||||
'ripple',
|
||||
'none',
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
settings: dict | None = None
|
||||
opacity: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
blur: float | None = Field(default=None, ge=0, le=100)
|
||||
reducedOnMobile: bool | None = None
|
||||
|
||||
@field_validator('settings')
|
||||
@classmethod
|
||||
def validate_settings(cls, v: dict | None) -> dict | None:
|
||||
if v is None:
|
||||
return v
|
||||
return _validate_settings(v)
|
||||
|
||||
|
||||
class FullscreenEnabledResponse(BaseModel):
|
||||
"""Fullscreen enabled setting."""
|
||||
|
||||
@@ -296,7 +394,7 @@ async def get_logo():
|
||||
@router.put('/name', response_model=BrandingResponse)
|
||||
async def update_branding_name(
|
||||
payload: BrandingNameUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update the project name. Admin only. Empty name allowed (logo only mode)."""
|
||||
@@ -307,7 +405,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()
|
||||
@@ -324,7 +422,7 @@ async def update_branding_name(
|
||||
@router.post('/logo', response_model=BrandingResponse)
|
||||
async def upload_logo(
|
||||
file: UploadFile = File(...),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Upload a custom logo. Admin only."""
|
||||
@@ -368,7 +466,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)
|
||||
@@ -387,7 +485,7 @@ async def upload_logo(
|
||||
|
||||
@router.delete('/logo', response_model=BrandingResponse)
|
||||
async def delete_logo(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete custom logo and revert to letter. Admin only."""
|
||||
@@ -398,7 +496,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)
|
||||
@@ -459,7 +557,7 @@ async def get_theme_colors(
|
||||
@router.patch('/colors', response_model=ThemeColorsResponse)
|
||||
async def update_theme_colors(
|
||||
payload: ThemeColorsUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update theme colors. Admin only. Partial update supported."""
|
||||
@@ -486,21 +584,21 @@ 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)
|
||||
|
||||
|
||||
@router.post('/colors/reset', response_model=ThemeColorsResponse)
|
||||
async def reset_theme_colors(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset theme colors to defaults. Admin only."""
|
||||
# Save default colors
|
||||
await set_setting_value(db, THEME_COLORS_KEY, json.dumps(DEFAULT_THEME_COLORS))
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} reset theme colors to defaults')
|
||||
logger.info('Admin reset theme colors to defaults', telegram_id=admin.telegram_id)
|
||||
|
||||
return ThemeColorsResponse(**DEFAULT_THEME_COLORS)
|
||||
|
||||
@@ -533,7 +631,7 @@ async def get_enabled_themes(
|
||||
@router.patch('/themes', response_model=EnabledThemesResponse)
|
||||
async def update_enabled_themes(
|
||||
payload: EnabledThemesUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update which themes are enabled. Admin only. At least one theme must be enabled."""
|
||||
@@ -558,7 +656,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)
|
||||
|
||||
@@ -587,17 +685,80 @@ async def get_animation_enabled(
|
||||
@router.patch('/animation', response_model=AnimationEnabledResponse)
|
||||
async def update_animation_enabled(
|
||||
payload: AnimationEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update animation enabled setting. Admin only."""
|
||||
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set animation enabled: {payload.enabled}')
|
||||
logger.info('Admin set animation enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return AnimationEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
|
||||
# ============ Animation Config Routes (new JSON-based) ============
|
||||
|
||||
|
||||
@router.get('/animation-config', response_model=AnimationConfigResponse)
|
||||
async def get_animation_config(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get full animation config. Public endpoint."""
|
||||
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
|
||||
|
||||
if config_value is not None:
|
||||
try:
|
||||
config = json.loads(config_value)
|
||||
return AnimationConfigResponse(**config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Auto-migrate from old ANIMATION_ENABLED_KEY
|
||||
old_value = await get_setting_value(db, ANIMATION_ENABLED_KEY)
|
||||
if old_value is not None:
|
||||
config = {**DEFAULT_ANIMATION_CONFIG, 'enabled': old_value.lower() == 'true'}
|
||||
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(config))
|
||||
return AnimationConfigResponse(**config)
|
||||
|
||||
return AnimationConfigResponse(**DEFAULT_ANIMATION_CONFIG)
|
||||
|
||||
|
||||
@router.patch('/animation-config', response_model=AnimationConfigResponse)
|
||||
async def update_animation_config(
|
||||
payload: AnimationConfigUpdate,
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update animation config (partial update). Admin only."""
|
||||
# Get current config
|
||||
config_value = await get_setting_value(db, ANIMATION_CONFIG_KEY)
|
||||
if config_value:
|
||||
try:
|
||||
current = json.loads(config_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
current = dict(DEFAULT_ANIMATION_CONFIG)
|
||||
else:
|
||||
current = dict(DEFAULT_ANIMATION_CONFIG)
|
||||
|
||||
# Merge only provided fields
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
current.update(update_data)
|
||||
|
||||
await set_setting_value(db, ANIMATION_CONFIG_KEY, json.dumps(current))
|
||||
|
||||
# Also sync old key for backwards compat
|
||||
await set_setting_value(db, ANIMATION_ENABLED_KEY, str(current.get('enabled', True)).lower())
|
||||
|
||||
logger.info(
|
||||
'Admin updated animation config',
|
||||
telegram_id=admin.telegram_id,
|
||||
type=current.get('type'),
|
||||
enabled=current.get('enabled'),
|
||||
)
|
||||
|
||||
return AnimationConfigResponse(**current)
|
||||
|
||||
|
||||
# ============ Fullscreen Routes ============
|
||||
|
||||
|
||||
@@ -622,13 +783,13 @@ async def get_fullscreen_enabled(
|
||||
@router.patch('/fullscreen', response_model=FullscreenEnabledResponse)
|
||||
async def update_fullscreen_enabled(
|
||||
payload: FullscreenEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update fullscreen enabled setting. Admin only."""
|
||||
await set_setting_value(db, FULLSCREEN_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set fullscreen enabled: {payload.enabled}')
|
||||
logger.info('Admin set fullscreen enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return FullscreenEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -658,13 +819,13 @@ async def get_email_auth_enabled(
|
||||
@router.patch('/email-auth', response_model=EmailAuthEnabledResponse)
|
||||
async def update_email_auth_enabled(
|
||||
payload: EmailAuthEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update email auth enabled setting. Admin only."""
|
||||
await set_setting_value(db, EMAIL_AUTH_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set email auth enabled: {payload.enabled}')
|
||||
logger.info('Admin set email auth enabled', telegram_id=admin.telegram_id, enabled=payload.enabled)
|
||||
|
||||
return EmailAuthEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -694,7 +855,7 @@ async def get_analytics_counters(
|
||||
@router.patch('/analytics', response_model=AnalyticsCountersResponse)
|
||||
async def update_analytics_counters(
|
||||
payload: AnalyticsCountersUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update analytics counter settings. Admin only. Partial update supported."""
|
||||
@@ -719,7 +880,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 ''
|
||||
@@ -758,12 +919,12 @@ async def get_lite_mode_enabled(
|
||||
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def update_lite_mode_enabled(
|
||||
payload: LiteModeEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('settings:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update lite mode enabled setting. Admin only."""
|
||||
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}')
|
||||
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 datetime import UTC, 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'])
|
||||
|
||||
@@ -102,11 +102,11 @@ async def _award_prize(db: AsyncSession, user_id: int, prize_type: str, prize_va
|
||||
return 'Error: subscription not found'
|
||||
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -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,7 +15,7 @@ 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'])
|
||||
|
||||
|
||||
@@ -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 datetime import UTC, 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'])
|
||||
|
||||
@@ -112,7 +112,7 @@ async def update_notification_settings(
|
||||
user.notification_settings = {}
|
||||
|
||||
user.notification_settings = new_settings
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
+59
-15
@@ -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
|
||||
@@ -12,6 +12,7 @@ from app.database.crud.user import (
|
||||
create_user_by_oauth,
|
||||
get_user_by_email,
|
||||
get_user_by_oauth_provider,
|
||||
get_user_by_referral_code,
|
||||
set_user_oauth_provider_id,
|
||||
)
|
||||
from app.database.models import User
|
||||
@@ -24,20 +25,35 @@ from ..auth.oauth_providers import (
|
||||
)
|
||||
from ..dependencies import get_cabinet_db
|
||||
from ..schemas.auth import AuthResponse
|
||||
from .auth import _create_auth_response, _store_refresh_token
|
||||
from .auth import _create_auth_response, _process_campaign_bonus, _store_refresh_token
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
|
||||
|
||||
|
||||
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
|
||||
async def _finalize_oauth_login(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
provider: str,
|
||||
campaign_slug: str | None = None,
|
||||
referral_code: str | None = None,
|
||||
) -> AuthResponse:
|
||||
"""Update last login, create tokens, store refresh token."""
|
||||
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
|
||||
user.cabinet_last_login = datetime.now(UTC)
|
||||
await db.commit()
|
||||
auth_response = _create_auth_response(user)
|
||||
auth_response = await _create_auth_response(user, db)
|
||||
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
|
||||
|
||||
# Process referral code (before campaign bonus, which may also set referrer)
|
||||
from .auth import _process_referral_code, _user_to_response
|
||||
|
||||
await _process_referral_code(db, user, referral_code)
|
||||
|
||||
auth_response.campaign_bonus = await _process_campaign_bonus(db, user, campaign_slug)
|
||||
if auth_response.campaign_bonus:
|
||||
auth_response.user = _user_to_response(user)
|
||||
return auth_response
|
||||
|
||||
|
||||
@@ -61,6 +77,10 @@ class OAuthAuthorizeResponse(BaseModel):
|
||||
class OAuthCallbackRequest(BaseModel):
|
||||
code: str = Field(..., description='Authorization code from provider')
|
||||
state: str = Field(..., description='CSRF state token')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
@@ -120,7 +140,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 +150,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,18 +159,41 @@ 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)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
|
||||
# 6. Find user by email (if verified) and link provider
|
||||
if user_info.email and user_info.email_verified:
|
||||
user = await get_user_by_email(db, user_info.email)
|
||||
if user:
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
logger.info('OAuth login via %s linked to existing email user %s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
|
||||
# 7. Create new user
|
||||
# 7. Resolve referral code for new user
|
||||
referrer_id = None
|
||||
if request.referral_code:
|
||||
try:
|
||||
referrer = await get_user_by_referral_code(db, request.referral_code)
|
||||
if referrer:
|
||||
# Self-referral protection by email
|
||||
if (
|
||||
user_info.email
|
||||
and user_info.email_verified
|
||||
and referrer.email
|
||||
and referrer.email.lower() == user_info.email.lower()
|
||||
):
|
||||
logger.warning(
|
||||
'Self-referral attempt blocked via OAuth',
|
||||
referral_code=request.referral_code,
|
||||
email=user_info.email,
|
||||
)
|
||||
else:
|
||||
referrer_id = referrer.id
|
||||
except Exception as e:
|
||||
logger.warning('Failed to resolve referral code during OAuth', referral_code=request.referral_code, error=e)
|
||||
|
||||
# 8. Create new user
|
||||
user = await create_user_by_oauth(
|
||||
db=db,
|
||||
provider=provider,
|
||||
@@ -160,6 +203,7 @@ async def oauth_callback(
|
||||
first_name=user_info.first_name,
|
||||
last_name=user_info.last_name,
|
||||
username=user_info.username,
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
logger.info('OAuth new user created via %s with id=%s', provider, user.id)
|
||||
return await _finalize_oauth_login(db, user, provider)
|
||||
logger.info('OAuth new user created via with id', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""User-facing partner application routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import AdvertisingCampaign, User
|
||||
from app.services.partner_application_service import partner_application_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.partners import (
|
||||
PartnerApplicationInfo,
|
||||
PartnerApplicationRequest,
|
||||
PartnerCampaignInfo,
|
||||
PartnerStatusResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
|
||||
|
||||
|
||||
def _get_campaign_deep_link(start_parameter: str) -> str | None:
|
||||
"""Generate Telegram deep link for campaign."""
|
||||
bot_username = settings.get_bot_username()
|
||||
if bot_username:
|
||||
return f'https://t.me/{bot_username}?start={start_parameter}'
|
||||
return None
|
||||
|
||||
|
||||
def _get_campaign_web_link(start_parameter: str) -> str | None:
|
||||
"""Generate web link for campaign."""
|
||||
base_url = (settings.MINIAPP_CUSTOM_URL or '').rstrip('/')
|
||||
if base_url:
|
||||
return f'{base_url}/?campaign={start_parameter}'
|
||||
return None
|
||||
|
||||
|
||||
@router.get('/status', response_model=PartnerStatusResponse)
|
||||
async def get_partner_status(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get partner status and latest application for current user."""
|
||||
latest_app = await partner_application_service.get_latest_application(db, user.id)
|
||||
|
||||
app_info = None
|
||||
if latest_app:
|
||||
app_info = PartnerApplicationInfo(
|
||||
id=latest_app.id,
|
||||
status=latest_app.status,
|
||||
company_name=latest_app.company_name,
|
||||
website_url=latest_app.website_url,
|
||||
telegram_channel=latest_app.telegram_channel,
|
||||
description=latest_app.description,
|
||||
expected_monthly_referrals=latest_app.expected_monthly_referrals,
|
||||
admin_comment=latest_app.admin_comment,
|
||||
approved_commission_percent=latest_app.approved_commission_percent,
|
||||
created_at=latest_app.created_at,
|
||||
processed_at=latest_app.processed_at,
|
||||
)
|
||||
|
||||
commission = user.referral_commission_percent
|
||||
if commission is None and user.is_partner:
|
||||
commission = settings.REFERRAL_COMMISSION_PERCENT
|
||||
|
||||
# Fetch campaigns assigned to this partner
|
||||
campaigns: list[PartnerCampaignInfo] = []
|
||||
if user.is_partner:
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaign).where(
|
||||
AdvertisingCampaign.partner_user_id == user.id,
|
||||
AdvertisingCampaign.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
for c in result.scalars().all():
|
||||
campaigns.append(
|
||||
PartnerCampaignInfo(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
start_parameter=c.start_parameter,
|
||||
bonus_type=c.bonus_type,
|
||||
balance_bonus_kopeks=c.balance_bonus_kopeks or 0,
|
||||
subscription_duration_days=c.subscription_duration_days,
|
||||
subscription_traffic_gb=c.subscription_traffic_gb,
|
||||
deep_link=_get_campaign_deep_link(c.start_parameter),
|
||||
web_link=_get_campaign_web_link(c.start_parameter),
|
||||
)
|
||||
)
|
||||
|
||||
return PartnerStatusResponse(
|
||||
partner_status=user.partner_status,
|
||||
commission_percent=commission,
|
||||
latest_application=app_info,
|
||||
campaigns=campaigns,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/apply', response_model=PartnerApplicationInfo)
|
||||
async def apply_for_partner(
|
||||
request: PartnerApplicationRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Submit partner application."""
|
||||
application, error = await partner_application_service.submit_application(
|
||||
db,
|
||||
user_id=user.id,
|
||||
company_name=request.company_name,
|
||||
website_url=request.website_url,
|
||||
telegram_channel=request.telegram_channel,
|
||||
description=request.description,
|
||||
expected_monthly_referrals=request.expected_monthly_referrals,
|
||||
)
|
||||
|
||||
if not application:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Уведомляем админов о новой заявке
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_partner_application_notification(
|
||||
user=user,
|
||||
application_data={
|
||||
'company_name': request.company_name,
|
||||
'telegram_channel': request.telegram_channel,
|
||||
'website_url': request.website_url,
|
||||
'description': request.description,
|
||||
'expected_monthly_referrals': request.expected_monthly_referrals,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for partner application', error=e)
|
||||
|
||||
return PartnerApplicationInfo(
|
||||
id=application.id,
|
||||
status=application.status,
|
||||
company_name=application.company_name,
|
||||
website_url=application.website_url,
|
||||
telegram_channel=application.telegram_channel,
|
||||
description=application.description,
|
||||
expected_monthly_referrals=application.expected_monthly_referrals,
|
||||
admin_comment=application.admin_comment,
|
||||
approved_commission_percent=application.approved_commission_percent,
|
||||
created_at=application.created_at,
|
||||
processed_at=application.processed_at,
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Polls routes for cabinet - user participation in polls/surveys."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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'])
|
||||
|
||||
@@ -247,7 +247,7 @@ async def start_poll(
|
||||
|
||||
# Mark as started if not already
|
||||
if not response.started_at:
|
||||
response.started_at = datetime.utcnow()
|
||||
response.started_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Get next unanswered question
|
||||
@@ -346,7 +346,7 @@ async def answer_question(
|
||||
)
|
||||
|
||||
# Poll completed
|
||||
response.completed_at = datetime.utcnow()
|
||||
response.completed_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Award reward if any
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Promo offers routes for cabinet - personal discounts and offers."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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'])
|
||||
|
||||
@@ -112,7 +112,7 @@ async def get_promo_offers(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of available promo offers for the user."""
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
@@ -151,7 +151,7 @@ async def get_active_discount(
|
||||
expires_at = user.promo_offer_discount_expires_at
|
||||
source = user.promo_offer_discount_source
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
is_active = discount_percent > 0 and (expires_at is None or expires_at > now)
|
||||
|
||||
return ActiveDiscountInfo(
|
||||
@@ -284,7 +284,7 @@ async def claim_promo_offer(
|
||||
detail='Offer not found',
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if offer.claimed_at is not None:
|
||||
raise HTTPException(
|
||||
@@ -408,7 +408,7 @@ async def clear_active_discount(
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
user.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -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,15 +1,15 @@
|
||||
"""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
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import ReferralEarning, User
|
||||
from app.database.models import AdvertisingCampaign, ReferralEarning, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.referral import (
|
||||
@@ -22,7 +22,7 @@ from ..schemas.referral import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral', tags=['Cabinet Referral'])
|
||||
|
||||
@@ -150,12 +150,26 @@ async def get_referral_earnings(
|
||||
result = await db.execute(query)
|
||||
earnings = result.scalars().all()
|
||||
|
||||
# Batch-fetch referral users to avoid N+1
|
||||
referral_ids = list({e.referral_id for e in earnings if e.referral_id})
|
||||
if referral_ids:
|
||||
referral_users_result = await db.execute(select(User).where(User.id.in_(referral_ids)))
|
||||
referral_users_map = {u.id: u for u in referral_users_result.scalars().all()}
|
||||
else:
|
||||
referral_users_map = {}
|
||||
|
||||
# Batch-fetch campaigns to avoid N+1
|
||||
campaign_ids = list({e.campaign_id for e in earnings if e.campaign_id})
|
||||
if campaign_ids:
|
||||
campaigns_result = await db.execute(select(AdvertisingCampaign).where(AdvertisingCampaign.id.in_(campaign_ids)))
|
||||
campaigns_map = {c.id: c for c in campaigns_result.scalars().all()}
|
||||
else:
|
||||
campaigns_map = {}
|
||||
|
||||
items = []
|
||||
for e in earnings:
|
||||
# Get referral user info
|
||||
referral_query = select(User).where(User.id == e.referral_id)
|
||||
referral_result = await db.execute(referral_query)
|
||||
referral_user = referral_result.scalar_one_or_none()
|
||||
referral_user = referral_users_map.get(e.referral_id) if e.referral_id else None
|
||||
campaign = campaigns_map.get(e.campaign_id) if e.campaign_id else None
|
||||
|
||||
items.append(
|
||||
ReferralEarningResponse(
|
||||
@@ -165,6 +179,7 @@ async def get_referral_earnings(
|
||||
reason=e.reason or 'Referral commission',
|
||||
referral_username=referral_user.username if referral_user else None,
|
||||
referral_first_name=referral_user.first_name if referral_user else None,
|
||||
campaign_name=campaign.name if campaign else None,
|
||||
created_at=e.created_at,
|
||||
)
|
||||
)
|
||||
@@ -194,4 +209,5 @@ async def get_referral_terms():
|
||||
first_topup_bonus_rubles=settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS / 100,
|
||||
inviter_bonus_kopeks=settings.REFERRAL_INVITER_BONUS_KOPEKS,
|
||||
inviter_bonus_rubles=settings.REFERRAL_INVITER_BONUS_KOPEKS / 100,
|
||||
partner_section_visible=settings.REFERRAL_PARTNER_SECTION_VISIBLE,
|
||||
)
|
||||
|
||||
+196
-431
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -10,10 +10,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database.crud.ticket_notification import TicketNotificationCRUD
|
||||
from app.database.models import User
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user, get_current_cabinet_user
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user, require_permission
|
||||
|
||||
|
||||
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'])
|
||||
@@ -132,7 +132,7 @@ async def get_admin_notifications(
|
||||
unread_only: bool = Query(False, description='Only return unread notifications'),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get ticket notifications for admins."""
|
||||
@@ -149,7 +149,7 @@ async def get_admin_notifications(
|
||||
|
||||
@admin_router.get('/unread-count', response_model=UnreadCountResponse)
|
||||
async def get_admin_unread_count(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get unread notifications count for admins."""
|
||||
@@ -160,7 +160,7 @@ async def get_admin_unread_count(
|
||||
@admin_router.post('/{notification_id}/read')
|
||||
async def mark_admin_notification_as_read(
|
||||
notification_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark an admin notification as read."""
|
||||
@@ -185,7 +185,7 @@ async def mark_admin_notification_as_read(
|
||||
|
||||
@admin_router.post('/read-all')
|
||||
async def mark_all_admin_notifications_as_read(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark all admin notifications as read."""
|
||||
@@ -196,7 +196,7 @@ async def mark_all_admin_notifications_as_read(
|
||||
@admin_router.post('/ticket/{ticket_id}/read')
|
||||
async def mark_admin_ticket_notifications_as_read(
|
||||
ticket_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('tickets:settings')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark all admin notifications for a specific ticket as read."""
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Support tickets routes for cabinet."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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'])
|
||||
|
||||
@@ -137,8 +137,8 @@ async def create_ticket(
|
||||
title=request.title,
|
||||
status='open',
|
||||
priority='normal',
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
@@ -152,7 +152,7 @@ async def create_ticket(
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
created_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
await db.commit()
|
||||
@@ -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]
|
||||
|
||||
@@ -268,23 +268,29 @@ async def add_ticket_message(
|
||||
media_type=request.media_type,
|
||||
media_file_id=request.media_file_id,
|
||||
media_caption=request.media_caption,
|
||||
created_at=datetime.utcnow(),
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
# Update ticket status and timestamp
|
||||
if ticket.status == 'answered':
|
||||
ticket.status = 'pending'
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
ticket.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
|
||||
# Уведомить админов об ответе пользователя (Telegram)
|
||||
try:
|
||||
await notify_admins_about_ticket_reply(ticket, request.message, db)
|
||||
await notify_admins_about_ticket_reply(
|
||||
ticket,
|
||||
request.message,
|
||||
db,
|
||||
media_file_id=request.media_file_id,
|
||||
media_type=request.media_type,
|
||||
)
|
||||
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 +301,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,166 @@
|
||||
"""User-facing withdrawal routes for cabinet."""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import User, WithdrawalRequest, WithdrawalRequestStatus
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.withdrawals import (
|
||||
WithdrawalBalanceResponse,
|
||||
WithdrawalCreateRequest,
|
||||
WithdrawalCreateResponse,
|
||||
WithdrawalItemResponse,
|
||||
WithdrawalListResponse,
|
||||
)
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/referral/withdrawal', tags=['Cabinet Withdrawal'])
|
||||
|
||||
|
||||
@router.get('/balance', response_model=WithdrawalBalanceResponse)
|
||||
async def get_withdrawal_balance(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get withdrawal balance stats for current user."""
|
||||
can_request, reason, stats = await referral_withdrawal_service.can_request_withdrawal(db, user.id)
|
||||
|
||||
return WithdrawalBalanceResponse(
|
||||
total_earned=stats['total_earned'],
|
||||
referral_spent=stats['referral_spent'],
|
||||
withdrawn=stats['withdrawn'],
|
||||
pending=stats['pending'],
|
||||
available_referral=stats['available_referral'],
|
||||
available_total=stats['available_total'],
|
||||
only_referral_mode=stats['only_referral_mode'],
|
||||
min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
|
||||
is_withdrawal_enabled=settings.is_referral_withdrawal_enabled(),
|
||||
can_request=can_request,
|
||||
cannot_request_reason=reason if not can_request else None,
|
||||
requisites_text=settings.REFERRAL_WITHDRAWAL_REQUISITES_TEXT,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/create', response_model=WithdrawalCreateResponse)
|
||||
async def create_withdrawal(
|
||||
request: WithdrawalCreateRequest,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Create a withdrawal request."""
|
||||
withdrawal, error = await referral_withdrawal_service.create_withdrawal_request(
|
||||
db,
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
payment_details=request.payment_details,
|
||||
)
|
||||
|
||||
if not withdrawal:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Уведомляем админов о запросе на вывод
|
||||
try:
|
||||
from aiogram import Bot
|
||||
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN:
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
try:
|
||||
notification_service = AdminNotificationService(bot)
|
||||
await notification_service.send_withdrawal_request_notification(
|
||||
user=user,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
payment_details=request.payment_details,
|
||||
)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error('Failed to send admin notification for withdrawal request', error=e)
|
||||
|
||||
return WithdrawalCreateResponse(
|
||||
id=withdrawal.id,
|
||||
amount_kopeks=withdrawal.amount_kopeks,
|
||||
status=withdrawal.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/history', response_model=WithdrawalListResponse)
|
||||
async def get_withdrawal_history(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's withdrawal request history."""
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
result = await db.execute(
|
||||
select(WithdrawalRequest)
|
||||
.where(WithdrawalRequest.user_id == user.id)
|
||||
.order_by(desc(WithdrawalRequest.created_at))
|
||||
.limit(50)
|
||||
)
|
||||
requests = result.scalars().all()
|
||||
|
||||
items = [
|
||||
WithdrawalItemResponse(
|
||||
id=r.id,
|
||||
amount_kopeks=r.amount_kopeks,
|
||||
amount_rubles=r.amount_kopeks / 100,
|
||||
status=r.status,
|
||||
payment_details=r.payment_details,
|
||||
admin_comment=r.admin_comment,
|
||||
created_at=r.created_at,
|
||||
processed_at=r.processed_at,
|
||||
)
|
||||
for r in requests
|
||||
]
|
||||
|
||||
return WithdrawalListResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.post('/{request_id}/cancel')
|
||||
async def cancel_withdrawal(
|
||||
request_id: int,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Cancel a pending withdrawal request."""
|
||||
result = await db.execute(
|
||||
select(WithdrawalRequest)
|
||||
.where(
|
||||
WithdrawalRequest.id == request_id,
|
||||
WithdrawalRequest.user_id == user.id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
withdrawal = result.scalar_one_or_none()
|
||||
|
||||
if not withdrawal:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Заявка не найдена',
|
||||
)
|
||||
|
||||
if withdrawal.status != WithdrawalRequestStatus.PENDING.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Можно отменить только заявку в ожидании',
|
||||
)
|
||||
|
||||
withdrawal.status = WithdrawalRequestStatus.CANCELLED.value
|
||||
await db.commit()
|
||||
|
||||
return {'success': True}
|
||||
@@ -9,6 +9,10 @@ class TelegramAuthRequest(BaseModel):
|
||||
"""Request for Telegram WebApp initData authentication."""
|
||||
|
||||
init_data: str = Field(..., description='Telegram WebApp initData string')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
|
||||
|
||||
|
||||
class TelegramWidgetAuthRequest(BaseModel):
|
||||
@@ -21,6 +25,10 @@ class TelegramWidgetAuthRequest(BaseModel):
|
||||
photo_url: str | None = Field(None, description="User's photo URL")
|
||||
auth_date: int = Field(..., description='Unix timestamp of authentication')
|
||||
hash: str = Field(..., description='Authentication hash')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
|
||||
|
||||
|
||||
class EmailRegisterRequest(BaseModel):
|
||||
@@ -34,6 +42,9 @@ class EmailVerifyRequest(BaseModel):
|
||||
"""Request to verify email with token."""
|
||||
|
||||
token: str = Field(..., description='Email verification token')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class EmailLoginRequest(BaseModel):
|
||||
@@ -41,6 +52,9 @@ class EmailLoginRequest(BaseModel):
|
||||
|
||||
email: EmailStr = Field(..., description='Email address')
|
||||
password: str = Field(..., description='Password')
|
||||
campaign_slug: str | None = Field(
|
||||
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
|
||||
)
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
@@ -102,6 +116,16 @@ class EmailRegisterStandaloneRequest(BaseModel):
|
||||
referral_code: str | None = Field(None, max_length=32, description='Referral code of inviter')
|
||||
|
||||
|
||||
class CampaignBonusInfo(BaseModel):
|
||||
"""Info about campaign bonus applied during auth."""
|
||||
|
||||
campaign_name: str
|
||||
bonus_type: str
|
||||
balance_kopeks: int = 0
|
||||
subscription_days: int | None = None
|
||||
tariff_name: str | None = None
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
"""Full authentication response with tokens and user."""
|
||||
|
||||
@@ -110,6 +134,7 @@ class AuthResponse(BaseModel):
|
||||
token_type: str = 'bearer'
|
||||
expires_in: int
|
||||
user: UserResponse
|
||||
campaign_bonus: CampaignBonusInfo | None = None
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
|
||||
@@ -63,7 +63,7 @@ class PaymentMethodResponse(BaseModel):
|
||||
class TopUpRequest(BaseModel):
|
||||
"""Request to create payment for balance top-up."""
|
||||
|
||||
amount_kopeks: int = Field(..., ge=1000, description='Amount in kopeks (min 10 rubles)')
|
||||
amount_kopeks: int = Field(..., ge=1000, le=2_000_000_000, description='Amount in kopeks (min 10 rubles)')
|
||||
payment_method: str = Field(..., description='Payment method ID')
|
||||
payment_option: str | None = Field(None, description='Payment option (e.g. Platega method code)')
|
||||
|
||||
@@ -82,7 +82,7 @@ class TopUpResponse(BaseModel):
|
||||
class StarsInvoiceRequest(BaseModel):
|
||||
"""Request to create Telegram Stars invoice for balance top-up."""
|
||||
|
||||
amount_kopeks: int = Field(..., ge=100, description='Amount in kopeks (min 1 ruble)')
|
||||
amount_kopeks: int = Field(..., ge=100, le=2_000_000_000, description='Amount in kopeks (min 1 ruble)')
|
||||
|
||||
|
||||
class StarsInvoiceResponse(BaseModel):
|
||||
|
||||
@@ -114,6 +114,7 @@ class BroadcastResponse(BaseModel):
|
||||
total_count: int
|
||||
sent_count: int
|
||||
failed_count: int
|
||||
blocked_count: int = 0
|
||||
status: str # queued|in_progress|completed|partial|failed|cancelled|cancelling
|
||||
admin_id: int | None = None
|
||||
admin_name: str | None = None
|
||||
|
||||
@@ -27,6 +27,8 @@ class CampaignListItem(BaseModel):
|
||||
registrations_count: int
|
||||
total_revenue_kopeks: int = 0
|
||||
conversion_rate: float = 0.0
|
||||
partner_user_id: int | None = None
|
||||
partner_name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -60,12 +62,16 @@ class CampaignDetailResponse(BaseModel):
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = None
|
||||
tariff: TariffInfo | None = None
|
||||
# Partner
|
||||
partner_user_id: int | None = None
|
||||
partner_name: str | None = None
|
||||
# Meta
|
||||
created_by: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
# Deep link
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -75,7 +81,7 @@ class CampaignCreateRequest(BaseModel):
|
||||
"""Request to create a campaign."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
start_parameter: str = Field(..., min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
start_parameter: str = Field(..., min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: bool = True
|
||||
# Balance bonus
|
||||
@@ -88,13 +94,15 @@ class CampaignCreateRequest(BaseModel):
|
||||
# Tariff bonus
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = Field(None, ge=1)
|
||||
# Partner
|
||||
partner_user_id: int | None = None
|
||||
|
||||
|
||||
class CampaignUpdateRequest(BaseModel):
|
||||
"""Request to update a campaign."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
start_parameter: str | None = Field(None, min_length=1, max_length=100, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
start_parameter: str | None = Field(None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
bonus_type: CampaignBonusType | None = None
|
||||
is_active: bool | None = None
|
||||
# Balance bonus
|
||||
@@ -107,6 +115,8 @@ class CampaignUpdateRequest(BaseModel):
|
||||
# Tariff bonus
|
||||
tariff_id: int | None = None
|
||||
tariff_duration_days: int | None = Field(None, ge=1)
|
||||
# Partner
|
||||
partner_user_id: int | None = None
|
||||
|
||||
|
||||
class CampaignToggleResponse(BaseModel):
|
||||
@@ -147,6 +157,7 @@ class CampaignStatisticsResponse(BaseModel):
|
||||
trial_conversion_rate: float = 0.0
|
||||
# Deep link
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
|
||||
|
||||
class CampaignRegistrationItem(BaseModel):
|
||||
@@ -194,6 +205,14 @@ class CampaignsOverviewResponse(BaseModel):
|
||||
total_tariff_issued: int = 0
|
||||
|
||||
|
||||
class AvailablePartnerItem(BaseModel):
|
||||
"""Partner item for campaign partner selector."""
|
||||
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
|
||||
|
||||
class ServerSquadInfo(BaseModel):
|
||||
"""Server squad info for campaign selection."""
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Pydantic v2 schemas for channel subscription management."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.database.crud.required_channel import validate_channel_id as _validate_channel_id_format
|
||||
|
||||
|
||||
def _validate_channel_link_value(v: str | None) -> str | None:
|
||||
"""Shared channel_link validation: t.me URL, @username auto-convert, http->https upgrade."""
|
||||
if v is None:
|
||||
return v
|
||||
v = v.strip()
|
||||
if v.startswith('http://t.me/'):
|
||||
v = v.replace('http://', 'https://', 1)
|
||||
if v.startswith('https://t.me/'):
|
||||
return v
|
||||
if v.startswith('@'):
|
||||
return f'https://t.me/{v[1:]}'
|
||||
raise ValueError('channel_link must be a t.me URL or @username')
|
||||
|
||||
|
||||
class ChannelResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
channel_id: str
|
||||
channel_link: str | None
|
||||
title: str | None
|
||||
is_active: bool
|
||||
sort_order: int
|
||||
disable_trial_on_leave: bool
|
||||
disable_paid_on_leave: bool
|
||||
|
||||
|
||||
class ChannelListResponse(BaseModel):
|
||||
items: list[ChannelResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ChannelCreateRequest(BaseModel):
|
||||
channel_id: str
|
||||
channel_link: str | None = None
|
||||
title: str | None = Field(None, max_length=255)
|
||||
disable_trial_on_leave: bool = True
|
||||
disable_paid_on_leave: bool = False
|
||||
|
||||
@field_validator('channel_id')
|
||||
@classmethod
|
||||
def validate_channel_id(cls, v: str) -> str:
|
||||
return _validate_channel_id_format(v)
|
||||
|
||||
@field_validator('channel_link')
|
||||
@classmethod
|
||||
def validate_channel_link(cls, v: str | None) -> str | None:
|
||||
return _validate_channel_link_value(v)
|
||||
|
||||
|
||||
class ChannelUpdateRequest(BaseModel):
|
||||
channel_id: str | None = None
|
||||
channel_link: str | None = None
|
||||
title: str | None = Field(None, max_length=255)
|
||||
is_active: bool | None = None
|
||||
sort_order: int | None = None
|
||||
disable_trial_on_leave: bool | None = None
|
||||
disable_paid_on_leave: bool | None = None
|
||||
|
||||
@field_validator('channel_id')
|
||||
@classmethod
|
||||
def validate_channel_id(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
return _validate_channel_id_format(v)
|
||||
|
||||
@field_validator('channel_link')
|
||||
@classmethod
|
||||
def validate_channel_link(cls, v: str | None) -> str | None:
|
||||
return _validate_channel_link_value(v)
|
||||
|
||||
|
||||
class ChannelSubscriptionStatus(BaseModel):
|
||||
channel_id: str
|
||||
channel_link: str | None
|
||||
title: str | None
|
||||
is_subscribed: bool
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Partner system schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ==================== User-facing ====================
|
||||
|
||||
|
||||
class PartnerApplicationRequest(BaseModel):
|
||||
"""Request to apply for partner status."""
|
||||
|
||||
company_name: str | None = Field(None, max_length=255)
|
||||
website_url: str | None = Field(None, max_length=500)
|
||||
telegram_channel: str | None = Field(None, max_length=255)
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
expected_monthly_referrals: int | None = Field(None, ge=0, le=2_000_000_000)
|
||||
|
||||
|
||||
class PartnerApplicationInfo(BaseModel):
|
||||
"""Application info for the user."""
|
||||
|
||||
id: int
|
||||
status: str
|
||||
company_name: str | None = None
|
||||
website_url: str | None = None
|
||||
telegram_channel: str | None = None
|
||||
description: str | None = None
|
||||
expected_monthly_referrals: int | None = None
|
||||
admin_comment: str | None = None
|
||||
approved_commission_percent: int | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PartnerCampaignInfo(BaseModel):
|
||||
"""Campaign info visible to the partner."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
bonus_type: str
|
||||
balance_bonus_kopeks: int = 0
|
||||
subscription_duration_days: int | None = None
|
||||
subscription_traffic_gb: int | None = None
|
||||
deep_link: str | None = None
|
||||
web_link: str | None = None
|
||||
|
||||
|
||||
class PartnerStatusResponse(BaseModel):
|
||||
"""Partner status for current user."""
|
||||
|
||||
partner_status: str
|
||||
commission_percent: int | None = None
|
||||
latest_application: PartnerApplicationInfo | None = None
|
||||
campaigns: list[PartnerCampaignInfo] = []
|
||||
|
||||
|
||||
# ==================== Admin-facing ====================
|
||||
|
||||
|
||||
class AdminPartnerApplicationItem(BaseModel):
|
||||
"""Partner application in admin list."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
company_name: str | None = None
|
||||
website_url: str | None = None
|
||||
telegram_channel: str | None = None
|
||||
description: str | None = None
|
||||
expected_monthly_referrals: int | None = None
|
||||
status: str
|
||||
admin_comment: str | None = None
|
||||
approved_commission_percent: int | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminPartnerApplicationsResponse(BaseModel):
|
||||
"""List of partner applications."""
|
||||
|
||||
items: list[AdminPartnerApplicationItem]
|
||||
total: int
|
||||
|
||||
|
||||
class AdminApproveRequest(BaseModel):
|
||||
"""Request to approve a partner application."""
|
||||
|
||||
commission_percent: int = Field(..., ge=1, le=100)
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class AdminRejectRequest(BaseModel):
|
||||
"""Request to reject a partner application."""
|
||||
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class AdminPartnerItem(BaseModel):
|
||||
"""Partner in admin list."""
|
||||
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
commission_percent: int | None = None
|
||||
total_referrals: int = 0
|
||||
total_earnings_kopeks: int = 0
|
||||
balance_kopeks: int = 0
|
||||
partner_status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminPartnerListResponse(BaseModel):
|
||||
"""List of partners for admin."""
|
||||
|
||||
items: list[AdminPartnerItem]
|
||||
total: int
|
||||
|
||||
|
||||
class CampaignSummary(BaseModel):
|
||||
"""Campaign summary for partner detail."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
start_parameter: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class AdminPartnerDetailResponse(BaseModel):
|
||||
"""Detailed partner info for admin."""
|
||||
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
commission_percent: int | None = None
|
||||
partner_status: str
|
||||
balance_kopeks: int = 0
|
||||
total_referrals: int = 0
|
||||
paid_referrals: int = 0
|
||||
active_referrals: int = 0
|
||||
earnings_all_time: int = 0
|
||||
earnings_today: int = 0
|
||||
earnings_week: int = 0
|
||||
earnings_month: int = 0
|
||||
conversion_to_paid: float = 0.0
|
||||
campaigns: list[CampaignSummary] = []
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminUpdateCommissionRequest(BaseModel):
|
||||
"""Request to update partner commission."""
|
||||
|
||||
commission_percent: int = Field(..., ge=1, le=100)
|
||||
@@ -47,6 +47,7 @@ class ReferralEarningResponse(BaseModel):
|
||||
reason: str
|
||||
referral_username: str | None = None
|
||||
referral_first_name: str | None = None
|
||||
campaign_name: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
@@ -76,3 +77,4 @@ class ReferralTermsResponse(BaseModel):
|
||||
first_topup_bonus_rubles: float
|
||||
inviter_bonus_kopeks: int
|
||||
inviter_bonus_rubles: float
|
||||
partner_section_visible: bool = True
|
||||
|
||||
@@ -56,6 +56,7 @@ class SubscriptionData(BaseModel):
|
||||
next_daily_charge_at: datetime | None = None # When next daily charge will happen
|
||||
tariff_id: int | None = None
|
||||
tariff_name: str | None = None
|
||||
traffic_reset_mode: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -85,7 +86,7 @@ class RenewalOptionResponse(BaseModel):
|
||||
class RenewalRequest(BaseModel):
|
||||
"""Request to renew subscription."""
|
||||
|
||||
period_days: int = Field(..., description='Renewal period in days')
|
||||
period_days: int = Field(..., ge=1, le=3650, description='Renewal period in days')
|
||||
|
||||
|
||||
class TrafficPackageResponse(BaseModel):
|
||||
@@ -100,13 +101,13 @@ class TrafficPackageResponse(BaseModel):
|
||||
class TrafficPurchaseRequest(BaseModel):
|
||||
"""Request to purchase additional traffic."""
|
||||
|
||||
gb: int = Field(..., ge=0, description='GB to purchase (0 = unlimited)')
|
||||
gb: int = Field(..., ge=0, le=100_000, description='GB to purchase (0 = unlimited)')
|
||||
|
||||
|
||||
class DevicePurchaseRequest(BaseModel):
|
||||
"""Request to purchase additional device slots."""
|
||||
|
||||
devices: int = Field(..., ge=1, description='Number of additional devices')
|
||||
devices: int = Field(..., ge=1, le=100, description='Number of additional devices')
|
||||
|
||||
|
||||
class AutopayUpdateRequest(BaseModel):
|
||||
@@ -136,10 +137,10 @@ class PurchaseSelectionRequest(BaseModel):
|
||||
"""User's selection for subscription purchase."""
|
||||
|
||||
period_id: str | None = Field(None, description="Period ID like 'days:30'")
|
||||
period_days: int | None = Field(None, description='Period in days')
|
||||
traffic_value: int | None = Field(None, description='Traffic in GB (0 = unlimited)')
|
||||
period_days: int | None = Field(None, ge=1, le=3650, description='Period in days')
|
||||
traffic_value: int | None = Field(None, ge=0, le=100_000, description='Traffic in GB (0 = unlimited)')
|
||||
servers: list[str] | None = Field(default_factory=list, description='Server UUIDs')
|
||||
devices: int | None = Field(None, description='Device limit')
|
||||
devices: int | None = Field(None, ge=1, le=100, description='Device limit')
|
||||
|
||||
|
||||
class PurchasePreviewRequest(BaseModel):
|
||||
@@ -155,5 +156,7 @@ class TariffPurchaseRequest(BaseModel):
|
||||
"""Request to purchase a tariff."""
|
||||
|
||||
tariff_id: int = Field(..., description='Tariff ID to purchase')
|
||||
period_days: int = Field(..., description='Period in days')
|
||||
traffic_gb: int | None = Field(None, ge=0, description='Custom traffic in GB (for custom_traffic_enabled tariffs)')
|
||||
period_days: int = Field(..., ge=1, le=3650, description='Period in days')
|
||||
traffic_gb: int | None = Field(
|
||||
None, ge=0, le=100_000, description='Custom traffic in GB (for custom_traffic_enabled tariffs)'
|
||||
)
|
||||
|
||||
@@ -261,7 +261,9 @@ class UserNodeUsageResponse(BaseModel):
|
||||
class UpdateBalanceRequest(BaseModel):
|
||||
"""Request to update user balance."""
|
||||
|
||||
amount_kopeks: int = Field(..., description='Amount in kopeks (positive to add, negative to subtract)')
|
||||
amount_kopeks: int = Field(
|
||||
..., ge=-2_000_000_000, le=2_000_000_000, description='Amount in kopeks (positive to add, negative to subtract)'
|
||||
)
|
||||
description: str = Field(default='Admin balance adjustment', max_length=500)
|
||||
create_transaction: bool = Field(default=True, description='Create transaction record')
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Withdrawal system schemas for cabinet."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ==================== User-facing ====================
|
||||
|
||||
|
||||
class WithdrawalBalanceResponse(BaseModel):
|
||||
"""Withdrawal balance info for user."""
|
||||
|
||||
total_earned: int
|
||||
referral_spent: int
|
||||
withdrawn: int
|
||||
pending: int
|
||||
available_referral: int
|
||||
available_total: int
|
||||
only_referral_mode: bool
|
||||
min_amount_kopeks: int
|
||||
is_withdrawal_enabled: bool
|
||||
can_request: bool
|
||||
cannot_request_reason: str | None = None
|
||||
requisites_text: str = ''
|
||||
|
||||
|
||||
class WithdrawalCreateRequest(BaseModel):
|
||||
"""Request to create a withdrawal."""
|
||||
|
||||
amount_kopeks: int = Field(..., gt=0, le=10_000_000)
|
||||
payment_details: str = Field(..., min_length=5, max_length=1000)
|
||||
|
||||
|
||||
class WithdrawalItemResponse(BaseModel):
|
||||
"""Withdrawal request item."""
|
||||
|
||||
id: int
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
payment_details: str | None = None
|
||||
admin_comment: str | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class WithdrawalListResponse(BaseModel):
|
||||
"""List of user's withdrawal requests."""
|
||||
|
||||
items: list[WithdrawalItemResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class WithdrawalCreateResponse(BaseModel):
|
||||
"""Response after creating withdrawal."""
|
||||
|
||||
id: int
|
||||
amount_kopeks: int
|
||||
status: str
|
||||
|
||||
|
||||
# ==================== Admin-facing ====================
|
||||
|
||||
|
||||
class AdminWithdrawalItem(BaseModel):
|
||||
"""Withdrawal request in admin list."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
risk_score: int = 0
|
||||
risk_level: str = 'low'
|
||||
payment_details: str | None = None
|
||||
admin_comment: str | None = None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminWithdrawalListResponse(BaseModel):
|
||||
"""List of withdrawal requests for admin."""
|
||||
|
||||
items: list[AdminWithdrawalItem]
|
||||
total: int
|
||||
pending_count: int = 0
|
||||
pending_total_kopeks: int = 0
|
||||
|
||||
|
||||
class AdminWithdrawalDetailResponse(BaseModel):
|
||||
"""Detailed withdrawal request for admin."""
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
username: str | None = None
|
||||
first_name: str | None = None
|
||||
telegram_id: int | None = None
|
||||
amount_kopeks: int
|
||||
amount_rubles: float
|
||||
status: str
|
||||
risk_score: int = 0
|
||||
risk_level: str = 'low'
|
||||
risk_analysis: dict | None = None
|
||||
payment_details: str | None = None
|
||||
admin_comment: str | None = None
|
||||
balance_kopeks: int = 0
|
||||
total_referrals: int = 0
|
||||
total_earnings_kopeks: int = 0
|
||||
created_at: datetime
|
||||
processed_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminApproveWithdrawalRequest(BaseModel):
|
||||
"""Request to approve a withdrawal."""
|
||||
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class AdminRejectWithdrawalRequest(BaseModel):
|
||||
"""Request to reject a withdrawal."""
|
||||
|
||||
comment: str | None = Field(None, max_length=2000)
|
||||
@@ -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(
|
||||
|
||||
@@ -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 datetime import UTC, 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
|
||||
|
||||
|
||||
@@ -122,7 +124,7 @@ async def save_template_override(
|
||||
)
|
||||
row = existing.fetchone()
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if row:
|
||||
# Update
|
||||
|
||||
@@ -4,6 +4,7 @@ Email notification templates for different notification types.
|
||||
Supports multiple languages: ru, en, zh, ua, fa
|
||||
"""
|
||||
|
||||
import html
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
@@ -53,6 +54,10 @@ class EmailNotificationTemplates:
|
||||
NotificationType.WARNING_NOTIFICATION: self._warning_template,
|
||||
NotificationType.REFERRAL_BONUS: self._referral_bonus_template,
|
||||
NotificationType.REFERRAL_REGISTERED: self._referral_registered_template,
|
||||
NotificationType.PARTNER_APPLICATION_APPROVED: self._partner_approved_template,
|
||||
NotificationType.PARTNER_APPLICATION_REJECTED: self._partner_rejected_template,
|
||||
NotificationType.WITHDRAWAL_APPROVED: self._withdrawal_approved_template,
|
||||
NotificationType.WITHDRAWAL_REJECTED: self._withdrawal_rejected_template,
|
||||
NotificationType.TRAFFIC_RESET: self._traffic_reset_template,
|
||||
NotificationType.PAYMENT_RECEIVED: self._payment_received_template,
|
||||
NotificationType.EMAIL_VERIFICATION: self._email_verification_template,
|
||||
@@ -528,7 +533,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _autopay_failed_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for failed autopay notification."""
|
||||
reason = context.get('reason', '')
|
||||
reason = html.escape(context.get('reason', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Ошибка автопродления',
|
||||
@@ -715,7 +720,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _ban_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for ban notification."""
|
||||
reason = context.get('reason', '')
|
||||
reason = html.escape(context.get('reason', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Аккаунт заблокирован',
|
||||
@@ -783,7 +788,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _warning_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for warning notification."""
|
||||
message = context.get('message', '')
|
||||
message = html.escape(context.get('message', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Предупреждение',
|
||||
@@ -819,7 +824,7 @@ class EmailNotificationTemplates:
|
||||
def _referral_bonus_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for referral bonus notification."""
|
||||
bonus = context.get('formatted_bonus', f'{context.get("bonus_rubles", 0):.2f} ₽')
|
||||
referral_name = context.get('referral_name', '')
|
||||
referral_name = html.escape(context.get('referral_name', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': f'Реферальный бонус: +{bonus}',
|
||||
@@ -856,7 +861,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
def _referral_registered_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for new referral registered notification."""
|
||||
referral_name = context.get('referral_name', '')
|
||||
referral_name = html.escape(context.get('referral_name', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Новый реферал зарегистрирован',
|
||||
@@ -889,6 +894,249 @@ class EmailNotificationTemplates:
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Partner Templates
|
||||
# ============================================================================
|
||||
|
||||
def _partner_approved_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for partner application approved notification."""
|
||||
commission = context.get('commission_percent', 0)
|
||||
comment = html.escape(context.get('comment', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Заявка на партнёрство одобрена',
|
||||
'en': 'Partner Application Approved',
|
||||
'zh': '合作伙伴申请已批准',
|
||||
'ua': 'Заявка на партнерство схвалена',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Заявка на партнёрство одобрена!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Ваша заявка на партнёрство была одобрена.</p>
|
||||
<p>Ваша комиссия: <strong>{commission}%</strong></p>
|
||||
{f'<p>Комментарий: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Теперь вы можете приглашать пользователей и получать вознаграждение!</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>Partner Application Approved!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Your partner application has been approved.</p>
|
||||
<p>Your commission rate: <strong>{commission}%</strong></p>
|
||||
{f'<p>Comment: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>You can now invite users and earn rewards!</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>合作伙伴申请已批准!</h2>
|
||||
<div class="highlight success">
|
||||
<p>您的合作伙伴申请已获批准。</p>
|
||||
<p>您的佣金比例: <strong>{commission}%</strong></p>
|
||||
{f'<p>备注: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>您现在可以邀请用户并获得奖励!</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Заявка на партнерство схвалена!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Вашу заявку на партнерство було схвалено.</p>
|
||||
<p>Ваша комісія: <strong>{commission}%</strong></p>
|
||||
{f'<p>Коментар: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Тепер ви можете запрошувати користувачів та отримувати винагороду!</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
def _partner_rejected_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for partner application rejected notification."""
|
||||
comment = html.escape(context.get('comment', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': 'Заявка на партнёрство отклонена',
|
||||
'en': 'Partner Application Rejected',
|
||||
'zh': '合作伙伴申请被拒绝',
|
||||
'ua': 'Заявка на партнерство відхилена',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Заявка на партнёрство отклонена</h2>
|
||||
<div class="highlight danger">
|
||||
<p>К сожалению, ваша заявка на партнёрство была отклонена.</p>
|
||||
{f'<p>Причина: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Вы можете подать новую заявку позже.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>Partner Application Rejected</h2>
|
||||
<div class="highlight danger">
|
||||
<p>Unfortunately, your partner application has been rejected.</p>
|
||||
{f'<p>Reason: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>You can submit a new application later.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>合作伙伴申请被拒绝</h2>
|
||||
<div class="highlight danger">
|
||||
<p>很抱歉,您的合作伙伴申请已被拒绝。</p>
|
||||
{f'<p>原因: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>您可以稍后提交新的申请。</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Заявка на партнерство відхилена</h2>
|
||||
<div class="highlight danger">
|
||||
<p>На жаль, вашу заявку на партнерство було відхилено.</p>
|
||||
{f'<p>Причина: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Ви можете подати нову заявку пізніше.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Withdrawal Templates
|
||||
# ============================================================================
|
||||
|
||||
def _withdrawal_approved_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for withdrawal approved notification."""
|
||||
amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f} ₽')
|
||||
comment = html.escape(context.get('comment', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': f'Запрос на вывод {amount} одобрен',
|
||||
'en': f'Withdrawal request for {amount} approved',
|
||||
'zh': f'提现请求 {amount} 已批准',
|
||||
'ua': f'Запит на виведення {amount} схвалено',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Запрос на вывод одобрен!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Ваш запрос на вывод средств одобрен.</p>
|
||||
<p>Сумма: <span class="amount">{amount}</span></p>
|
||||
{f'<p>Комментарий: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Средства будут переведены в ближайшее время.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>Withdrawal Request Approved!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Your withdrawal request has been approved.</p>
|
||||
<p>Amount: <span class="amount">{amount}</span></p>
|
||||
{f'<p>Comment: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Funds will be transferred shortly.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>提现请求已批准!</h2>
|
||||
<div class="highlight success">
|
||||
<p>您的提现请求已获批准。</p>
|
||||
<p>金额: <span class="amount">{amount}</span></p>
|
||||
{f'<p>备注: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>资金将很快转入。</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Запит на виведення схвалено!</h2>
|
||||
<div class="highlight success">
|
||||
<p>Ваш запит на виведення коштів було схвалено.</p>
|
||||
<p>Сума: <span class="amount">{amount}</span></p>
|
||||
{f'<p>Коментар: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Кошти будуть переведені найближчим часом.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
def _withdrawal_rejected_template(self, language: str, context: dict[str, Any]) -> dict[str, str]:
|
||||
"""Template for withdrawal rejected notification."""
|
||||
amount = context.get('formatted_amount', f'{context.get("amount_rubles", 0):.2f} ₽')
|
||||
comment = html.escape(context.get('comment', ''))
|
||||
|
||||
subjects = {
|
||||
'ru': f'Запрос на вывод {amount} отклонён',
|
||||
'en': f'Withdrawal request for {amount} rejected',
|
||||
'zh': f'提现请求 {amount} 被拒绝',
|
||||
'ua': f'Запит на виведення {amount} відхилено',
|
||||
}
|
||||
|
||||
bodies = {
|
||||
'ru': f"""
|
||||
<h2>Запрос на вывод отклонён</h2>
|
||||
<div class="highlight danger">
|
||||
<p>Ваш запрос на вывод средств был отклонён.</p>
|
||||
<p>Сумма: <strong>{amount}</strong></p>
|
||||
{f'<p>Причина: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Средства возвращены на ваш баланс.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'en': f"""
|
||||
<h2>Withdrawal Request Rejected</h2>
|
||||
<div class="highlight danger">
|
||||
<p>Your withdrawal request has been rejected.</p>
|
||||
<p>Amount: <strong>{amount}</strong></p>
|
||||
{f'<p>Reason: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Funds have been returned to your balance.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'zh': f"""
|
||||
<h2>提现请求被拒绝</h2>
|
||||
<div class="highlight danger">
|
||||
<p>您的提现请求已被拒绝。</p>
|
||||
<p>金额: <strong>{amount}</strong></p>
|
||||
{f'<p>原因: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>资金已退回您的余额。</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
'ua': f"""
|
||||
<h2>Запит на виведення відхилено</h2>
|
||||
<div class="highlight danger">
|
||||
<p>Ваш запит на виведення коштів було відхилено.</p>
|
||||
<p>Сума: <strong>{amount}</strong></p>
|
||||
{f'<p>Причина: {comment}</p>' if comment else ''}
|
||||
</div>
|
||||
<p>Кошти повернуто на ваш баланс.</p>
|
||||
{self._get_cabinet_button(language)}
|
||||
""",
|
||||
}
|
||||
|
||||
return {
|
||||
'subject': subjects.get(language, subjects['ru']),
|
||||
'body_html': self._get_base_template(bodies.get(language, bodies['ru']), language),
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Payment Templates
|
||||
# ============================================================================
|
||||
|
||||
+47
-45
@@ -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):
|
||||
@@ -66,8 +66,6 @@ class Settings(BaseSettings):
|
||||
ADMIN_REPORTS_TOPIC_ID: int | None = None
|
||||
ADMIN_REPORTS_SEND_TIME: str | None = None
|
||||
|
||||
CHANNEL_SUB_ID: str | None = None
|
||||
CHANNEL_LINK: str | None = None
|
||||
CHANNEL_IS_REQUIRED_SUB: bool = False
|
||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
|
||||
CHANNEL_REQUIRED_FOR_ALL: bool = False
|
||||
@@ -230,7 +228,9 @@ class Settings(BaseSettings):
|
||||
REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS: int = 100000 # Мин. сумма вывода (1000₽)
|
||||
REFERRAL_WITHDRAWAL_COOLDOWN_DAYS: int = 30 # Частота запросов на вывод
|
||||
REFERRAL_WITHDRAWAL_ONLY_REFERRAL_BALANCE: bool = True # Только реф. баланс (False = реф + свой)
|
||||
REFERRAL_WITHDRAWAL_REQUISITES_TEXT: str = '' # Текст-подсказка для реквизитов при выводе
|
||||
REFERRAL_WITHDRAWAL_NOTIFICATIONS_TOPIC_ID: int | None = None # Топик для уведомлений
|
||||
REFERRAL_PARTNER_SECTION_VISIBLE: bool = True # Показывать раздел партнёрки в кабинете
|
||||
|
||||
# Настройки анализа на подозрительность
|
||||
REFERRAL_WITHDRAWAL_SUSPICIOUS_MIN_DEPOSIT_KOPEKS: int = 50000 # Мин. сумма от 1 реферала (500₽)
|
||||
@@ -500,6 +500,11 @@ class Settings(BaseSettings):
|
||||
FREEKASSA_USE_API: bool = False
|
||||
# Публичный IP сервера для Freekassa API (если не задан - определяется автоматически)
|
||||
SERVER_PUBLIC_IP: str | None = None
|
||||
# Раздельные методы оплаты Freekassa (отображаются как отдельные кнопки)
|
||||
FREEKASSA_SBP_ENABLED: bool = False # СБП (QR код) — i=44
|
||||
FREEKASSA_SBP_DISPLAY_NAME: str = 'СБП (QR код)'
|
||||
FREEKASSA_CARD_ENABLED: bool = False # Карты РФ — i=36
|
||||
FREEKASSA_CARD_DISPLAY_NAME: str = 'Карта РФ'
|
||||
|
||||
# KassaAI (api.fk.life) - отдельная платёжка
|
||||
KASSA_AI_ENABLED: bool = False
|
||||
@@ -549,6 +554,7 @@ class Settings(BaseSettings):
|
||||
|
||||
LOG_LEVEL: str = 'INFO'
|
||||
LOG_FILE: str = 'logs/bot.log'
|
||||
LOG_COLORS: bool = True # ANSI-цвета в консоли (false для plain-text вывода)
|
||||
|
||||
# === Log Rotation Settings ===
|
||||
LOG_ROTATION_ENABLED: bool = False # По умолчанию старое поведение
|
||||
@@ -670,9 +676,9 @@ class Settings(BaseSettings):
|
||||
WEB_API_DEFAULT_TOKEN: str | None = None
|
||||
WEB_API_DEFAULT_TOKEN_NAME: str = 'Bootstrap Token'
|
||||
WEB_API_TOKEN_HASH_ALGORITHM: str = 'sha256'
|
||||
WEB_API_TOKEN_HMAC_SECRET: str | None = None
|
||||
WEB_API_REQUEST_LOGGING: bool = True
|
||||
|
||||
APP_CONFIG_PATH: str = 'app-config.json'
|
||||
ENABLE_DEEP_LINKS: bool = True
|
||||
APP_CONFIG_CACHE_TTL: int = 3600
|
||||
|
||||
@@ -1297,7 +1303,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:
|
||||
@@ -1317,17 +1323,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
|
||||
@@ -1382,13 +1385,6 @@ class Settings(BaseSettings):
|
||||
return value
|
||||
return None
|
||||
|
||||
def get_app_config_path(self) -> str:
|
||||
if os.path.isabs(self.APP_CONFIG_PATH):
|
||||
return self.APP_CONFIG_PATH
|
||||
|
||||
project_root = Path(__file__).parent.parent
|
||||
return str(project_root / self.APP_CONFIG_PATH)
|
||||
|
||||
def is_deep_links_enabled(self) -> bool:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
@@ -1448,9 +1444,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
|
||||
|
||||
@@ -1531,14 +1527,11 @@ 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:
|
||||
return 0
|
||||
if value <= 0:
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
@@ -1574,8 +1567,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
|
||||
|
||||
@@ -1694,7 +1686,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)
|
||||
@@ -1763,6 +1755,26 @@ class Settings(BaseSettings):
|
||||
def get_freekassa_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_display_name())
|
||||
|
||||
def is_freekassa_sbp_enabled(self) -> bool:
|
||||
return self.FREEKASSA_SBP_ENABLED and self.is_freekassa_enabled()
|
||||
|
||||
def get_freekassa_sbp_display_name(self) -> str:
|
||||
name = (self.FREEKASSA_SBP_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'СБП (QR код)'
|
||||
|
||||
def get_freekassa_sbp_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_sbp_display_name())
|
||||
|
||||
def is_freekassa_card_enabled(self) -> bool:
|
||||
return self.FREEKASSA_CARD_ENABLED and self.is_freekassa_enabled()
|
||||
|
||||
def get_freekassa_card_display_name(self) -> str:
|
||||
name = (self.FREEKASSA_CARD_DISPLAY_NAME or '').strip()
|
||||
return name if name else 'Карта РФ'
|
||||
|
||||
def get_freekassa_card_display_name_html(self) -> str:
|
||||
return html.escape(self.get_freekassa_card_display_name())
|
||||
|
||||
def is_kassa_ai_enabled(self) -> bool:
|
||||
return (
|
||||
self.KASSA_AI_ENABLED
|
||||
@@ -1789,8 +1801,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
|
||||
|
||||
@@ -2144,22 +2156,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:
|
||||
@@ -2178,11 +2181,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:
|
||||
|
||||
@@ -8,7 +8,7 @@ from .database import (
|
||||
get_db,
|
||||
get_db_read_only,
|
||||
get_pool_metrics,
|
||||
init_db,
|
||||
sync_postgres_sequences,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,5 +20,5 @@ __all__ = [
|
||||
'get_db',
|
||||
'get_db_read_only',
|
||||
'get_pool_metrics',
|
||||
'init_db',
|
||||
'sync_postgres_sequences',
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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(
|
||||
@@ -36,6 +36,7 @@ async def create_campaign(
|
||||
tariff_id: int | None = None,
|
||||
tariff_duration_days: int | None = None,
|
||||
is_active: bool = True,
|
||||
partner_user_id: int | None = None,
|
||||
) -> AdvertisingCampaign:
|
||||
campaign = AdvertisingCampaign(
|
||||
name=name,
|
||||
@@ -50,6 +51,7 @@ async def create_campaign(
|
||||
tariff_duration_days=tariff_duration_days,
|
||||
created_by=created_by,
|
||||
is_active=is_active,
|
||||
partner_user_id=partner_user_id,
|
||||
)
|
||||
|
||||
db.add(campaign)
|
||||
@@ -57,10 +59,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
|
||||
|
||||
@@ -71,6 +73,7 @@ async def get_campaign_by_id(db: AsyncSession, campaign_id: int) -> AdvertisingC
|
||||
.options(
|
||||
selectinload(AdvertisingCampaign.registrations),
|
||||
selectinload(AdvertisingCampaign.tariff),
|
||||
selectinload(AdvertisingCampaign.partner),
|
||||
)
|
||||
.where(AdvertisingCampaign.id == campaign_id)
|
||||
)
|
||||
@@ -103,6 +106,7 @@ async def get_campaigns_list(
|
||||
.options(
|
||||
selectinload(AdvertisingCampaign.registrations),
|
||||
selectinload(AdvertisingCampaign.tariff),
|
||||
selectinload(AdvertisingCampaign.partner),
|
||||
)
|
||||
.order_by(AdvertisingCampaign.created_at.desc())
|
||||
.offset(offset)
|
||||
@@ -141,6 +145,7 @@ async def update_campaign(
|
||||
'tariff_id',
|
||||
'tariff_duration_days',
|
||||
'is_active',
|
||||
'partner_user_id',
|
||||
}
|
||||
|
||||
update_data = {}
|
||||
@@ -151,20 +156,20 @@ async def update_campaign(
|
||||
if not update_data:
|
||||
return campaign
|
||||
|
||||
update_data['updated_at'] = datetime.utcnow()
|
||||
update_data['updated_at'] = datetime.now(UTC)
|
||||
|
||||
await db.execute(update(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id).values(**update_data))
|
||||
await db.commit()
|
||||
await db.refresh(campaign)
|
||||
|
||||
logger.info('✏️ Обновлена рекламная кампания %s (%s)', campaign.name, update_data)
|
||||
logger.info('✏️ Обновлена рекламная кампания', 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 +222,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
|
||||
|
||||
|
||||
@@ -331,7 +336,7 @@ async def get_campaign_statistics(
|
||||
first_payment_time_by_user[user_id] = converted_at
|
||||
|
||||
for user_id, amount_kopeks, created_at in subscription_payments:
|
||||
amount_value = int(amount_kopeks or 0)
|
||||
amount_value = abs(int(amount_kopeks or 0))
|
||||
subscription_payments_total += amount_value
|
||||
paid_users_from_transactions.add(user_id)
|
||||
|
||||
@@ -359,66 +364,6 @@ async def get_campaign_statistics(
|
||||
if first_payment_amount_by_user:
|
||||
avg_first_payment = int(sum(first_payment_amount_by_user.values()) / len(first_payment_amount_by_user))
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
conversion_rate = round((paid_users_count / count) * 100, 1)
|
||||
|
||||
trial_conversion_rate = 0.0
|
||||
if trial_users_count:
|
||||
trial_conversion_rate = round((conversion_count / trial_users_count) * 100, 1)
|
||||
|
||||
avg_revenue_per_user = 0
|
||||
if count:
|
||||
avg_revenue_per_user = int(total_revenue / count)
|
||||
|
||||
deposits_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
Transaction.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Transaction.type == TransactionType.DEPOSIT.value,
|
||||
Transaction.is_completed.is_(True),
|
||||
)
|
||||
)
|
||||
total_revenue = deposits_result.scalar() or 0
|
||||
|
||||
trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
)
|
||||
)
|
||||
trial_users_count = trials_result.scalar() or 0
|
||||
|
||||
active_trials_result = await db.execute(
|
||||
select(func.count(func.distinct(Subscription.user_id))).where(
|
||||
Subscription.user_id.in_(select(registrations_subquery.c.user_id)),
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
active_trials_count = active_trials_result.scalar() or 0
|
||||
|
||||
conversions_result = await db.execute(
|
||||
select(func.count(func.distinct(SubscriptionConversion.user_id))).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
conversion_count = conversions_result.scalar() or 0
|
||||
|
||||
paid_users_result = await db.execute(
|
||||
select(func.count(User.id)).where(
|
||||
User.id.in_(select(registrations_subquery.c.user_id)),
|
||||
User.has_had_paid_subscription.is_(True),
|
||||
)
|
||||
)
|
||||
paid_users_count = paid_users_result.scalar() or 0
|
||||
|
||||
avg_first_payment_result = await db.execute(
|
||||
select(func.coalesce(func.avg(SubscriptionConversion.first_payment_amount_kopeks), 0)).where(
|
||||
SubscriptionConversion.user_id.in_(select(registrations_subquery.c.user_id))
|
||||
)
|
||||
)
|
||||
avg_first_payment = int(avg_first_payment_result.scalar() or 0)
|
||||
|
||||
conversion_rate = 0.0
|
||||
if count:
|
||||
conversion_rate = round((paid_users_count / count) * 100, 1)
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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
|
||||
@@ -127,7 +127,7 @@ async def update_cloudpayments_payment(
|
||||
if hasattr(payment, key):
|
||||
setattr(payment, key, value)
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
await db.refresh(payment)
|
||||
|
||||
@@ -171,7 +171,7 @@ async def mark_cloudpayments_payment_as_paid(
|
||||
|
||||
payment.status = 'completed'
|
||||
payment.is_paid = True
|
||||
payment.paid_at = datetime.utcnow()
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
|
||||
if transaction_id_cp is not None:
|
||||
payment.transaction_id_cp = transaction_id_cp
|
||||
@@ -190,14 +190,12 @@ async def mark_cloudpayments_payment_as_paid(
|
||||
if callback_payload:
|
||||
payment.callback_payload = callback_payload
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
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
|
||||
from datetime import UTC, 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
|
||||
@@ -107,7 +107,7 @@ async def create_round(
|
||||
|
||||
|
||||
async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
@@ -124,7 +124,7 @@ async def get_active_rounds(db: AsyncSession) -> list[ContestRound]:
|
||||
|
||||
|
||||
async def get_active_round_by_template(db: AsyncSession, template_id: int) -> ContestRound | None:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(ContestRound)
|
||||
.options(selectinload(ContestRound.template))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -70,7 +76,7 @@ async def update_cryptobot_payment_status(
|
||||
return None
|
||||
|
||||
payment.status = status
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if status == 'paid' and paid_at:
|
||||
payment.paid_at = paid_at
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -91,12 +97,12 @@ async def link_cryptobot_payment_to_transaction(
|
||||
return None
|
||||
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -114,9 +120,7 @@ async def get_user_cryptobot_payments(
|
||||
|
||||
|
||||
async def get_pending_cryptobot_payments(db: AsyncSession, older_than_hours: int = 24) -> list[CryptoBotPayment]:
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
||||
cutoff_time = datetime.now(UTC) - timedelta(hours=older_than_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(CryptoBotPayment)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, 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(
|
||||
@@ -28,7 +28,7 @@ async def upsert_discount_offer(
|
||||
) -> DiscountOffer:
|
||||
"""Create or refresh a discount offer for a user."""
|
||||
|
||||
expires_at = datetime.utcnow() + timedelta(hours=valid_hours)
|
||||
expires_at = datetime.now(UTC) + timedelta(hours=valid_hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(DiscountOffer)
|
||||
@@ -116,7 +116,7 @@ async def list_active_discount_offers_for_user(
|
||||
) -> list[DiscountOffer]:
|
||||
"""Return active (not yet claimed) offers for a user."""
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
stmt = (
|
||||
select(DiscountOffer)
|
||||
.options(
|
||||
@@ -161,7 +161,7 @@ async def mark_offer_claimed(
|
||||
*,
|
||||
details: dict | None = None,
|
||||
) -> DiscountOffer:
|
||||
offer.claimed_at = datetime.utcnow()
|
||||
offer.claimed_at = datetime.now(UTC)
|
||||
offer.is_active = False
|
||||
await db.commit()
|
||||
await db.refresh(offer)
|
||||
@@ -178,24 +178,19 @@ 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
|
||||
|
||||
|
||||
async def deactivate_expired_offers(db: AsyncSession) -> int:
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(DiscountOffer).where(
|
||||
DiscountOffer.is_active == True,
|
||||
@@ -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
|
||||
from datetime import UTC, 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:
|
||||
@@ -21,7 +21,7 @@ async def set_faq_enabled(db: AsyncSession, language: str, enabled: bool) -> Faq
|
||||
|
||||
if setting:
|
||||
setting.is_enabled = bool(enabled)
|
||||
setting.updated_at = datetime.utcnow()
|
||||
setting.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
setting = FaqSetting(
|
||||
language=language,
|
||||
@@ -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
|
||||
|
||||
@@ -117,12 +117,12 @@ async def update_faq_page(
|
||||
if is_active is not None:
|
||||
page.is_active = bool(is_active)
|
||||
|
||||
page.updated_at = datetime.utcnow()
|
||||
page.updated_at = datetime.now(UTC)
|
||||
|
||||
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(
|
||||
@@ -139,6 +139,6 @@ async def bulk_update_order(
|
||||
) -> None:
|
||||
for page_id, order in pages:
|
||||
await db.execute(
|
||||
update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.utcnow())
|
||||
update(FaqPage).where(FaqPage.id == page_id).values(display_order=order, updated_at=datetime.now(UTC))
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""CRUD операции для платежей Freekassa."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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
|
||||
|
||||
|
||||
@@ -77,10 +77,10 @@ async def update_freekassa_payment_status(
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.is_paid = is_paid
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.utcnow()
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if freekassa_order_id:
|
||||
payment.freekassa_order_id = freekassa_order_id
|
||||
if payment_system_id is not None:
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -129,7 +134,7 @@ async def get_expired_pending_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[FreekassaPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(FreekassaPayment).where(
|
||||
FreekassaPayment.status == 'pending',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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:
|
||||
@@ -129,17 +129,17 @@ async def update_heleket_payment(
|
||||
if paid_at is not None:
|
||||
payment.paid_at = paid_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
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,19 +153,15 @@ 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
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
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
|
||||
from datetime import UTC, 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
|
||||
|
||||
|
||||
@@ -79,10 +79,10 @@ async def update_kassa_ai_payment_status(
|
||||
"""Обновляет статус платежа."""
|
||||
payment.status = status
|
||||
payment.is_paid = is_paid
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
if is_paid:
|
||||
payment.paid_at = datetime.utcnow()
|
||||
payment.paid_at = datetime.now(UTC)
|
||||
if kassa_ai_order_id:
|
||||
payment.kassa_ai_order_id = kassa_ai_order_id
|
||||
if payment_system_id is not None:
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -131,7 +136,7 @@ async def get_expired_pending_kassa_ai_payments(
|
||||
db: AsyncSession,
|
||||
) -> list[KassaAiPayment]:
|
||||
"""Получает просроченные платежи в статусе pending."""
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(KassaAiPayment).where(
|
||||
KassaAiPayment.status == 'pending',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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
|
||||
@@ -90,7 +90,7 @@ async def update_mulenpay_payment_status(
|
||||
if metadata is not None:
|
||||
payment.metadata_json = metadata
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -103,7 +103,7 @@ async def update_mulenpay_payment_metadata(
|
||||
metadata: dict,
|
||||
) -> MulenPayPayment:
|
||||
payment.metadata_json = metadata
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
return payment
|
||||
@@ -116,7 +116,7 @@ async def link_mulenpay_payment_to_transaction(
|
||||
transaction_id: int,
|
||||
) -> MulenPayPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
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 datetime import UTC, 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
|
||||
@@ -115,7 +115,7 @@ async def update_platega_payment(
|
||||
if expires_at is not None:
|
||||
payment.expires_at = expires_at
|
||||
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
@@ -129,7 +129,7 @@ async def link_platega_payment_to_transaction(
|
||||
transaction_id: int,
|
||||
) -> PlategaPayment:
|
||||
payment.transaction_id = transaction_id
|
||||
payment.updated_at = datetime.utcnow()
|
||||
payment.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(payment)
|
||||
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
|
||||
from datetime import UTC, 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:
|
||||
@@ -26,7 +26,7 @@ async def upsert_privacy_policy(
|
||||
|
||||
if policy:
|
||||
policy.content = content or ''
|
||||
policy.updated_at = datetime.utcnow()
|
||||
policy.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
@@ -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
|
||||
|
||||
@@ -56,7 +52,7 @@ async def set_privacy_policy_enabled(
|
||||
|
||||
if policy:
|
||||
policy.is_enabled = bool(enabled)
|
||||
policy.updated_at = datetime.utcnow()
|
||||
policy.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
policy = PrivacyPolicy(
|
||||
language=language,
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -118,13 +118,13 @@ async def ensure_default_templates(db: AsyncSession, *, created_by: int | None =
|
||||
|
||||
if should_update and new_message:
|
||||
existing.message_text = new_message
|
||||
existing.updated_at = datetime.utcnow()
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
|
||||
target_active_hours = template_data.get('active_discount_hours')
|
||||
if target_active_hours is not None and target_active_hours > 0 and not existing.active_discount_hours:
|
||||
existing.active_discount_hours = target_active_hours
|
||||
existing.updated_at = datetime.utcnow()
|
||||
existing.updated_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
templates.append(existing)
|
||||
continue
|
||||
@@ -204,7 +204,7 @@ async def update_promo_offer_template(
|
||||
if is_active is not None:
|
||||
template.is_active = is_active
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
template.updated_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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 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
|
||||
|
||||
@@ -114,13 +114,13 @@ async def check_user_promocode_usage(db: AsyncSession, user_id: int, promocode_i
|
||||
|
||||
|
||||
async def create_promocode_use(db: AsyncSession, promocode_id: int, user_id: int) -> PromoCodeUse:
|
||||
promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.utcnow())
|
||||
promocode_use = PromoCodeUse(promocode_id=promocode_id, user_id=user_id, used_at=datetime.now(UTC))
|
||||
|
||||
db.add(promocode_use)
|
||||
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
|
||||
|
||||
|
||||
@@ -133,9 +133,7 @@ async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, pro
|
||||
|
||||
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)
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=hours)
|
||||
result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.used_at >= cutoff))
|
||||
)
|
||||
@@ -178,7 +176,7 @@ async def update_promocode(db: AsyncSession, promocode: PromoCode, **kwargs) ->
|
||||
if hasattr(promocode, field):
|
||||
setattr(promocode, field, value)
|
||||
|
||||
promocode.updated_at = datetime.utcnow()
|
||||
promocode.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(promocode)
|
||||
|
||||
@@ -194,11 +192,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
|
||||
|
||||
@@ -239,7 +237,7 @@ async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
|
||||
)
|
||||
total_uses = total_uses_result.scalar()
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
today = datetime.now(UTC).date()
|
||||
today_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(
|
||||
and_(PromoCodeUse.promocode_id == promocode_id, PromoCodeUse.used_at >= today)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import UTC, 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:
|
||||
@@ -26,7 +26,7 @@ async def upsert_public_offer(
|
||||
|
||||
if offer:
|
||||
offer.content = content or ''
|
||||
offer.updated_at = datetime.utcnow()
|
||||
offer.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
@@ -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
|
||||
|
||||
@@ -56,7 +52,7 @@ async def set_public_offer_enabled(
|
||||
|
||||
if offer:
|
||||
offer.is_enabled = bool(enabled)
|
||||
offer.updated_at = datetime.utcnow()
|
||||
offer.updated_at = datetime.now(UTC)
|
||||
else:
|
||||
offer = PublicOffer(
|
||||
language=language,
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import AccessPolicy, AdminAuditLog, AdminRole, User, UserRole
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Fields allowed for AdminRole.update()
|
||||
_ROLE_UPDATABLE_FIELDS = frozenset(
|
||||
{
|
||||
'name',
|
||||
'description',
|
||||
'level',
|
||||
'permissions',
|
||||
'color',
|
||||
'icon',
|
||||
'is_active',
|
||||
}
|
||||
)
|
||||
|
||||
# Fields allowed for AccessPolicy.update()
|
||||
_POLICY_UPDATABLE_FIELDS = frozenset(
|
||||
{
|
||||
'name',
|
||||
'description',
|
||||
'role_id',
|
||||
'priority',
|
||||
'effect',
|
||||
'conditions',
|
||||
'resource',
|
||||
'actions',
|
||||
'is_active',
|
||||
}
|
||||
)
|
||||
|
||||
# Superadmin level constant
|
||||
_SUPERADMIN_LEVEL = 999
|
||||
|
||||
|
||||
class AdminRoleCRUD:
|
||||
"""CRUD operations for admin_roles table."""
|
||||
|
||||
@staticmethod
|
||||
async def get_all(db: AsyncSession, *, include_inactive: bool = False) -> list[AdminRole]:
|
||||
"""Get all admin roles ordered by level descending."""
|
||||
stmt = select(AdminRole).order_by(AdminRole.level.desc())
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(AdminRole.is_active.is_(True))
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(db: AsyncSession, role_id: int) -> AdminRole | None:
|
||||
result = await db.execute(select(AdminRole).where(AdminRole.id == role_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_by_name(db: AsyncSession, name: str) -> AdminRole | None:
|
||||
result = await db.execute(select(AdminRole).where(AdminRole.name == name))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
name: str,
|
||||
description: str | None,
|
||||
level: int,
|
||||
permissions: list[str],
|
||||
color: str | None = None,
|
||||
icon: str | None = None,
|
||||
is_system: bool = False,
|
||||
created_by: int | None = None,
|
||||
) -> AdminRole:
|
||||
role = AdminRole(
|
||||
name=name,
|
||||
description=description,
|
||||
level=level,
|
||||
permissions=permissions,
|
||||
color=color,
|
||||
icon=icon,
|
||||
is_system=is_system,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(role)
|
||||
await db.flush()
|
||||
await db.refresh(role)
|
||||
logger.info('Created admin role', role_id=role.id, name=name, level=level)
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
async def update(db: AsyncSession, role_id: int, **kwargs: object) -> AdminRole | None:
|
||||
"""Update only provided fields. Rejects unknown/non-updatable keys."""
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if key not in _ROLE_UPDATABLE_FIELDS:
|
||||
logger.warning('Rejected update of non-updatable AdminRole field', field=key)
|
||||
continue
|
||||
setattr(role, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(role)
|
||||
logger.info('Updated admin role', role_id=role_id, fields=list(kwargs.keys()))
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
async def delete(db: AsyncSession, role_id: int) -> bool:
|
||||
"""Delete a role. Returns False if the role is a system role or does not exist.
|
||||
|
||||
Cascades are handled by DB-level ON DELETE CASCADE on user_roles and access_policies.
|
||||
"""
|
||||
role = await AdminRoleCRUD.get_by_id(db, role_id)
|
||||
if not role:
|
||||
return False
|
||||
if role.is_system:
|
||||
logger.warning('Attempted to delete system role', role_id=role_id, name=role.name)
|
||||
return False
|
||||
|
||||
# Explicitly delete dependent user_roles and access_policies in application layer
|
||||
# to keep audit trail clear (DB cascade would also work, but explicit is better)
|
||||
await db.execute(delete(UserRole).where(UserRole.role_id == role_id))
|
||||
await db.execute(delete(AccessPolicy).where(AccessPolicy.role_id == role_id))
|
||||
await db.delete(role)
|
||||
await db.flush()
|
||||
logger.info('Deleted admin role', role_id=role_id, name=role.name)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def count_users(db: AsyncSession, role_id: int) -> int:
|
||||
"""Count active user_roles assigned to this role."""
|
||||
result = await db.execute(
|
||||
select(func.count(UserRole.id)).where(
|
||||
UserRole.role_id == role_id,
|
||||
UserRole.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
class UserRoleCRUD:
|
||||
"""CRUD operations for user_roles table + permission aggregation."""
|
||||
|
||||
@staticmethod
|
||||
async def get_user_roles(db: AsyncSession, user_id: int) -> list[UserRole]:
|
||||
"""Get active user roles with eager-loaded AdminRole."""
|
||||
result = await db.execute(
|
||||
select(UserRole)
|
||||
.options(selectinload(UserRole.role))
|
||||
.where(
|
||||
UserRole.user_id == user_id,
|
||||
UserRole.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def get_user_permissions(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
) -> tuple[list[str], list[str], int]:
|
||||
"""Aggregate permissions from all active, non-expired roles.
|
||||
|
||||
Returns:
|
||||
(sorted_permissions, role_names, max_level)
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
select(UserRole)
|
||||
.options(selectinload(UserRole.role))
|
||||
.where(
|
||||
UserRole.user_id == user_id,
|
||||
UserRole.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
user_roles = result.scalars().all()
|
||||
|
||||
permissions: set[str] = set()
|
||||
role_names: list[str] = []
|
||||
max_level: int = 0
|
||||
|
||||
for ur in user_roles:
|
||||
# Skip expired assignments
|
||||
if ur.expires_at is not None and ur.expires_at <= now:
|
||||
continue
|
||||
role = ur.role
|
||||
if role is None or not role.is_active:
|
||||
continue
|
||||
permissions.update(role.permissions or [])
|
||||
role_names.append(role.name)
|
||||
max_level = max(max_level, role.level)
|
||||
|
||||
return sorted(permissions), role_names, max_level
|
||||
|
||||
@staticmethod
|
||||
async def assign_role(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
role_id: int,
|
||||
assigned_by: int | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> UserRole:
|
||||
"""Assign a role to a user. Reactivates existing inactive assignment if present."""
|
||||
# Check for existing assignment (active or inactive) due to unique constraint
|
||||
result = await db.execute(
|
||||
select(UserRole).where(
|
||||
UserRole.user_id == user_id,
|
||||
UserRole.role_id == role_id,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is not None:
|
||||
existing.is_active = True
|
||||
existing.assigned_by = assigned_by
|
||||
existing.assigned_at = datetime.now(UTC)
|
||||
existing.expires_at = expires_at
|
||||
await db.flush()
|
||||
await db.refresh(existing)
|
||||
logger.info('Reactivated user role', user_role_id=existing.id, user_id=user_id, role_id=role_id)
|
||||
return existing
|
||||
|
||||
user_role = UserRole(
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
assigned_by=assigned_by,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(user_role)
|
||||
await db.flush()
|
||||
await db.refresh(user_role)
|
||||
logger.info('Assigned role to user', user_role_id=user_role.id, user_id=user_id, role_id=role_id)
|
||||
return user_role
|
||||
|
||||
@staticmethod
|
||||
async def revoke_role(db: AsyncSession, user_role_id: int) -> bool:
|
||||
"""Soft-revoke: set is_active=False. Returns False if not found."""
|
||||
result = await db.execute(select(UserRole).where(UserRole.id == user_role_id))
|
||||
user_role = result.scalar_one_or_none()
|
||||
if not user_role:
|
||||
return False
|
||||
|
||||
user_role.is_active = False
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Revoked user role', user_role_id=user_role_id, user_id=user_role.user_id, role_id=user_role.role_id
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_all_admins(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Get users that have at least one active role.
|
||||
|
||||
Returns list of dicts: [{'user': User, 'role_names': [str, ...]}]
|
||||
"""
|
||||
# Subquery: aggregate role names per user
|
||||
role_agg = (
|
||||
select(
|
||||
UserRole.user_id,
|
||||
func.array_agg(AdminRole.name).label('role_names'),
|
||||
)
|
||||
.join(AdminRole, UserRole.role_id == AdminRole.id)
|
||||
.where(
|
||||
UserRole.is_active.is_(True),
|
||||
AdminRole.is_active.is_(True),
|
||||
)
|
||||
.group_by(UserRole.user_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(User, role_agg.c.role_names)
|
||||
.join(role_agg, User.id == role_agg.c.user_id)
|
||||
.order_by(User.id)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [{'user': row[0], 'role_names': list(row[1] or [])} for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_superadmin_count(db: AsyncSession) -> int:
|
||||
"""Count users with an active role at superadmin level (999)."""
|
||||
result = await db.execute(
|
||||
select(func.count(func.distinct(UserRole.user_id)))
|
||||
.join(AdminRole, UserRole.role_id == AdminRole.id)
|
||||
.where(
|
||||
UserRole.is_active.is_(True),
|
||||
AdminRole.is_active.is_(True),
|
||||
AdminRole.level == _SUPERADMIN_LEVEL,
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
class AccessPolicyCRUD:
|
||||
"""CRUD operations for access_policies table (ABAC)."""
|
||||
|
||||
@staticmethod
|
||||
async def get_all(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
role_id: int | None = None,
|
||||
) -> list[AccessPolicy]:
|
||||
"""Get active policies ordered by priority descending. Optionally filter by role_id."""
|
||||
stmt = select(AccessPolicy).where(AccessPolicy.is_active.is_(True)).order_by(AccessPolicy.priority.desc())
|
||||
if role_id is not None:
|
||||
stmt = stmt.where(AccessPolicy.role_id == role_id)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(db: AsyncSession, policy_id: int) -> AccessPolicy | None:
|
||||
result = await db.execute(select(AccessPolicy).where(AccessPolicy.id == policy_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def create(db: AsyncSession, **kwargs: object) -> AccessPolicy:
|
||||
policy = AccessPolicy(**kwargs)
|
||||
db.add(policy)
|
||||
await db.flush()
|
||||
await db.refresh(policy)
|
||||
logger.info('Created access policy', policy_id=policy.id, name=policy.name, effect=policy.effect)
|
||||
return policy
|
||||
|
||||
@staticmethod
|
||||
async def update(db: AsyncSession, policy_id: int, **kwargs: object) -> AccessPolicy | None:
|
||||
"""Update only provided fields. Rejects unknown/non-updatable keys."""
|
||||
policy = await AccessPolicyCRUD.get_by_id(db, policy_id)
|
||||
if not policy:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if key not in _POLICY_UPDATABLE_FIELDS:
|
||||
logger.warning('Rejected update of non-updatable AccessPolicy field', field=key)
|
||||
continue
|
||||
setattr(policy, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(policy)
|
||||
logger.info('Updated access policy', policy_id=policy_id, fields=list(kwargs.keys()))
|
||||
return policy
|
||||
|
||||
@staticmethod
|
||||
async def delete(db: AsyncSession, policy_id: int) -> bool:
|
||||
policy = await AccessPolicyCRUD.get_by_id(db, policy_id)
|
||||
if not policy:
|
||||
return False
|
||||
await db.delete(policy)
|
||||
await db.flush()
|
||||
logger.info('Deleted access policy', policy_id=policy_id, name=policy.name)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def get_policies_for_user(
|
||||
db: AsyncSession,
|
||||
role_ids: list[int],
|
||||
) -> list[AccessPolicy]:
|
||||
"""Get active policies matching any of the given role_ids OR global (role_id IS NULL).
|
||||
|
||||
Ordered by priority descending for correct evaluation order.
|
||||
"""
|
||||
if not role_ids:
|
||||
# Only global policies
|
||||
stmt = (
|
||||
select(AccessPolicy)
|
||||
.where(
|
||||
AccessPolicy.is_active.is_(True),
|
||||
AccessPolicy.role_id.is_(None),
|
||||
)
|
||||
.order_by(AccessPolicy.priority.desc())
|
||||
)
|
||||
else:
|
||||
stmt = (
|
||||
select(AccessPolicy)
|
||||
.where(
|
||||
AccessPolicy.is_active.is_(True),
|
||||
or_(
|
||||
AccessPolicy.role_id.in_(role_ids),
|
||||
AccessPolicy.role_id.is_(None),
|
||||
),
|
||||
)
|
||||
.order_by(AccessPolicy.priority.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
class AuditLogCRUD:
|
||||
"""Create + filtered query for admin_audit_log table."""
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
action: str,
|
||||
resource_type: str | None = None,
|
||||
resource_id: str | None = None,
|
||||
details: dict | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
status: str = 'success',
|
||||
request_method: str | None = None,
|
||||
request_path: str | None = None,
|
||||
) -> AdminAuditLog:
|
||||
entry = AdminAuditLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
status=status,
|
||||
request_method=request_method,
|
||||
request_path=request_path,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
await db.refresh(entry)
|
||||
logger.debug(
|
||||
'Audit log created',
|
||||
audit_id=entry.id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
status=status,
|
||||
)
|
||||
return entry
|
||||
|
||||
@staticmethod
|
||||
async def get_logs(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int | None = None,
|
||||
action: str | None = None,
|
||||
resource_type: str | None = None,
|
||||
status: str | None = None,
|
||||
date_from: datetime | None = None,
|
||||
date_to: datetime | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
load_user: bool = False,
|
||||
) -> tuple[list[AdminAuditLog], int]:
|
||||
"""Get filtered audit logs with total count.
|
||||
|
||||
Returns:
|
||||
(logs, total_count)
|
||||
"""
|
||||
filters = []
|
||||
if user_id is not None:
|
||||
filters.append(AdminAuditLog.user_id == user_id)
|
||||
if action is not None:
|
||||
filters.append(AdminAuditLog.action.ilike(f'%{action}%'))
|
||||
if resource_type is not None:
|
||||
filters.append(AdminAuditLog.resource_type == resource_type)
|
||||
if status is not None:
|
||||
filters.append(AdminAuditLog.status == status)
|
||||
if date_from is not None:
|
||||
filters.append(AdminAuditLog.created_at >= date_from)
|
||||
if date_to is not None:
|
||||
filters.append(AdminAuditLog.created_at <= date_to)
|
||||
|
||||
where_clause = and_(*filters) if filters else True
|
||||
|
||||
# Total count
|
||||
count_result = await db.execute(select(func.count(AdminAuditLog.id)).where(where_clause))
|
||||
total_count = count_result.scalar() or 0
|
||||
|
||||
# Paginated results
|
||||
stmt = (
|
||||
select(AdminAuditLog)
|
||||
.where(where_clause)
|
||||
.order_by(AdminAuditLog.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
if load_user:
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
stmt = stmt.options(selectinload(AdminAuditLog.user))
|
||||
result = await db.execute(stmt)
|
||||
logs = list(result.scalars().all())
|
||||
|
||||
return logs, total_count
|
||||
@@ -1,14 +1,25 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import ReferralEarning, User
|
||||
from app.database.models import AdvertisingCampaignRegistration, ReferralEarning, User
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def get_user_campaign_id(db: AsyncSession, user_id: int) -> int | None:
|
||||
"""Получить campaign_id первой регистрации пользователя."""
|
||||
result = await db.execute(
|
||||
select(AdvertisingCampaignRegistration.campaign_id)
|
||||
.where(AdvertisingCampaignRegistration.user_id == user_id)
|
||||
.order_by(AdvertisingCampaignRegistration.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_referral_earning(
|
||||
@@ -18,6 +29,7 @@ async def create_referral_earning(
|
||||
amount_kopeks: int,
|
||||
reason: str,
|
||||
referral_transaction_id: int | None = None,
|
||||
campaign_id: int | None = None,
|
||||
) -> ReferralEarning:
|
||||
earning = ReferralEarning(
|
||||
user_id=user_id,
|
||||
@@ -25,13 +37,16 @@ async def create_referral_earning(
|
||||
amount_kopeks=amount_kopeks,
|
||||
reason=reason,
|
||||
referral_transaction_id=referral_transaction_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
|
||||
db.add(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
|
||||
|
||||
|
||||
@@ -40,7 +55,11 @@ async def get_referral_earnings_by_user(
|
||||
) -> list[ReferralEarning]:
|
||||
result = await db.execute(
|
||||
select(ReferralEarning)
|
||||
.options(selectinload(ReferralEarning.referral), selectinload(ReferralEarning.referral_transaction))
|
||||
.options(
|
||||
selectinload(ReferralEarning.referral),
|
||||
selectinload(ReferralEarning.referral_transaction),
|
||||
selectinload(ReferralEarning.campaign),
|
||||
)
|
||||
.where(ReferralEarning.user_id == user_id)
|
||||
.order_by(ReferralEarning.created_at.desc())
|
||||
.offset(offset)
|
||||
@@ -176,7 +195,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
}
|
||||
)
|
||||
|
||||
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
today_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= today)
|
||||
@@ -188,7 +207,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
today_earnings = today_referral_earnings_result.scalar() + today_transaction_earnings_result.scalar()
|
||||
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
week_ago = datetime.now(UTC) - timedelta(days=7)
|
||||
week_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= week_ago)
|
||||
)
|
||||
@@ -199,7 +218,7 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
|
||||
)
|
||||
week_earnings = week_referral_earnings_result.scalar() + week_transaction_earnings_result.scalar()
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
month_referral_earnings_result = await db.execute(
|
||||
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(ReferralEarning.created_at >= month_ago)
|
||||
)
|
||||
@@ -211,7 +230,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 {
|
||||
@@ -244,7 +266,7 @@ async def get_top_referrers_by_period(
|
||||
"""
|
||||
from app.database.models import Transaction, TransactionType
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(UTC)
|
||||
if period == 'week':
|
||||
start_date = now - timedelta(days=7)
|
||||
else: # month
|
||||
@@ -375,12 +397,12 @@ async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
|
||||
|
||||
total_earned = await get_referral_earnings_sum(db, user_id)
|
||||
|
||||
month_ago = datetime.utcnow() - timedelta(days=30)
|
||||
month_ago = datetime.now(UTC) - timedelta(days=30)
|
||||
month_earned = await get_referral_earnings_sum(db, user_id, start_date=month_ago)
|
||||
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
active_referrals_result = await db.execute(
|
||||
select(func.count(User.id))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import date, datetime, time
|
||||
from datetime import UTC, 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(
|
||||
@@ -165,7 +165,7 @@ async def add_contest_event(
|
||||
referral_id=referral_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
event_type=event_type,
|
||||
occurred_at=datetime.utcnow(),
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
@@ -440,7 +440,7 @@ async def get_contest_transaction_breakdown(
|
||||
|
||||
# Сумма покупок подписок
|
||||
subscription_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
@@ -512,7 +512,7 @@ async def upsert_contest_event(
|
||||
referral_id=referral_id,
|
||||
amount_kopeks=amount_kopeks,
|
||||
event_type=event_type,
|
||||
occurred_at=datetime.utcnow(),
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(event)
|
||||
await db.commit()
|
||||
@@ -621,10 +621,10 @@ async def debug_contest_transactions(
|
||||
tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.DEPOSIT.value and tx.payment_method is not None
|
||||
)
|
||||
subscription_in_period = sum(
|
||||
tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value
|
||||
abs(tx.amount_kopeks) for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value
|
||||
)
|
||||
total_in_period = deposit_in_period + subscription_in_period
|
||||
total_outside = sum(tx.amount_kopeks for tx in txs_out)
|
||||
total_outside = sum(abs(tx.amount_kopeks) for tx in txs_out)
|
||||
|
||||
# Подсчёт ПОЛНЫХ сумм (не только sample, БЕЗ бонусов)
|
||||
full_deposit_result = await db.execute(
|
||||
@@ -642,7 +642,7 @@ async def debug_contest_transactions(
|
||||
full_deposit_total = int(full_deposit_result.scalar_one() or 0)
|
||||
|
||||
full_subscription_result = await db.execute(
|
||||
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id.in_(referral_ids),
|
||||
Transaction.is_completed.is_(True),
|
||||
@@ -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,
|
||||
@@ -768,7 +773,7 @@ async def sync_contest_events(
|
||||
|
||||
for event in events:
|
||||
# Считаем ТОЛЬКО покупки подписок (реальные траты на подписки)
|
||||
subscription_query = select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
|
||||
subscription_query = select(func.coalesce(func.sum(func.abs(Transaction.amount_kopeks)), 0)).where(
|
||||
and_(
|
||||
Transaction.user_id == event.referral_id,
|
||||
Transaction.is_completed.is_(True),
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import RequiredChannel, UserChannelSubscription
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Explicit allowlist of fields that can be updated via update_channel()
|
||||
_UPDATABLE_FIELDS = frozenset(
|
||||
{
|
||||
'channel_id',
|
||||
'channel_link',
|
||||
'title',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
'disable_trial_on_leave',
|
||||
'disable_paid_on_leave',
|
||||
}
|
||||
)
|
||||
|
||||
# Validation patterns for channel_id
|
||||
_CHANNEL_ID_NUMERIC = re.compile(r'^-100\d{10,13}$')
|
||||
_BARE_DIGITS = re.compile(r'^\d{10,13}$')
|
||||
|
||||
|
||||
def validate_channel_id(channel_id: str) -> str:
|
||||
"""Validate and normalize channel_id. Auto-prefixes -100 for bare digits.
|
||||
|
||||
Raises ValueError on invalid input.
|
||||
"""
|
||||
channel_id = channel_id.strip()
|
||||
if _CHANNEL_ID_NUMERIC.match(channel_id):
|
||||
return channel_id
|
||||
if _BARE_DIGITS.match(channel_id):
|
||||
return f'-100{channel_id}'
|
||||
raise ValueError(
|
||||
f'Invalid channel_id format: {channel_id!r}. '
|
||||
'Enter numeric channel ID (e.g. 1234567890) — prefix -100 is added automatically'
|
||||
)
|
||||
|
||||
|
||||
# -- RequiredChannel CRUD --------------------------------------------------------
|
||||
|
||||
|
||||
async def get_active_channels(db: AsyncSession) -> list[RequiredChannel]:
|
||||
"""Get all active required channels (sorted by sort_order)."""
|
||||
result = await db.execute(
|
||||
select(RequiredChannel)
|
||||
.where(RequiredChannel.is_active.is_(True))
|
||||
.order_by(RequiredChannel.sort_order, RequiredChannel.id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_all_channels(db: AsyncSession) -> list[RequiredChannel]:
|
||||
"""Get all required channels (including inactive)."""
|
||||
result = await db.execute(select(RequiredChannel).order_by(RequiredChannel.sort_order, RequiredChannel.id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_channel_by_id(db: AsyncSession, channel_db_id: int) -> RequiredChannel | None:
|
||||
result = await db.execute(select(RequiredChannel).where(RequiredChannel.id == channel_db_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_channel_by_channel_id(db: AsyncSession, channel_id: str) -> RequiredChannel | None:
|
||||
result = await db.execute(select(RequiredChannel).where(RequiredChannel.channel_id == channel_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def add_channel(
|
||||
db: AsyncSession,
|
||||
channel_id: str,
|
||||
channel_link: str | None = None,
|
||||
title: str | None = None,
|
||||
disable_trial_on_leave: bool = True,
|
||||
disable_paid_on_leave: bool = False,
|
||||
) -> RequiredChannel:
|
||||
channel_id = validate_channel_id(channel_id)
|
||||
channel = RequiredChannel(
|
||||
channel_id=channel_id,
|
||||
channel_link=channel_link,
|
||||
title=title,
|
||||
disable_trial_on_leave=disable_trial_on_leave,
|
||||
disable_paid_on_leave=disable_paid_on_leave,
|
||||
)
|
||||
db.add(channel)
|
||||
await db.commit()
|
||||
await db.refresh(channel)
|
||||
return channel
|
||||
|
||||
|
||||
async def update_channel(
|
||||
db: AsyncSession,
|
||||
channel_db_id: int,
|
||||
**kwargs,
|
||||
) -> RequiredChannel | None:
|
||||
"""Update channel fields. Only fields in _UPDATABLE_FIELDS are accepted."""
|
||||
channel = await get_channel_by_id(db, channel_db_id)
|
||||
if not channel:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if key not in _UPDATABLE_FIELDS:
|
||||
logger.warning('Rejected update of non-updatable field', field=key)
|
||||
continue
|
||||
if key == 'channel_id' and value is not None:
|
||||
value = validate_channel_id(value)
|
||||
setattr(channel, key, value)
|
||||
|
||||
channel.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(channel)
|
||||
return channel
|
||||
|
||||
|
||||
async def delete_channel(db: AsyncSession, channel_db_id: int) -> bool:
|
||||
channel = await get_channel_by_id(db, channel_db_id)
|
||||
if not channel:
|
||||
return False
|
||||
# Also clean up user subscriptions for this channel
|
||||
await db.execute(delete(UserChannelSubscription).where(UserChannelSubscription.channel_id == channel.channel_id))
|
||||
await db.delete(channel)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
async def toggle_channel(db: AsyncSession, channel_db_id: int) -> RequiredChannel | None:
|
||||
channel = await get_channel_by_id(db, channel_db_id)
|
||||
if not channel:
|
||||
return None
|
||||
channel.is_active = not channel.is_active
|
||||
channel.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(channel)
|
||||
return channel
|
||||
|
||||
|
||||
# -- UserChannelSubscription CRUD ------------------------------------------------
|
||||
|
||||
|
||||
async def upsert_user_channel_sub(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
channel_id: str,
|
||||
is_member: bool,
|
||||
) -> None:
|
||||
"""Upsert user subscription status (PostgreSQL ON CONFLICT)."""
|
||||
now = datetime.now(UTC) # Single timestamp for both INSERT and UPDATE
|
||||
stmt = (
|
||||
pg_insert(UserChannelSubscription)
|
||||
.values(
|
||||
telegram_id=telegram_id,
|
||||
channel_id=channel_id,
|
||||
is_member=is_member,
|
||||
checked_at=now,
|
||||
)
|
||||
.on_conflict_do_update(
|
||||
constraint='uq_user_channel_sub',
|
||||
set_={
|
||||
'is_member': is_member,
|
||||
'checked_at': now,
|
||||
},
|
||||
)
|
||||
)
|
||||
await db.execute(stmt)
|
||||
# NOTE: caller is responsible for commit (allows batching)
|
||||
|
||||
|
||||
async def get_user_channel_subs(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
) -> list[UserChannelSubscription]:
|
||||
"""Get all channel subscriptions for a user."""
|
||||
result = await db.execute(select(UserChannelSubscription).where(UserChannelSubscription.telegram_id == telegram_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_user_channel_sub(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
channel_id: str,
|
||||
) -> UserChannelSubscription | None:
|
||||
result = await db.execute(
|
||||
select(UserChannelSubscription).where(
|
||||
UserChannelSubscription.telegram_id == telegram_id,
|
||||
UserChannelSubscription.channel_id == channel_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def bulk_upsert_user_subs(
|
||||
db: AsyncSession,
|
||||
telegram_id: int,
|
||||
subs: dict[str, bool], # {channel_id: is_member}
|
||||
) -> None:
|
||||
"""Batch upsert user subscriptions with single multi-row INSERT."""
|
||||
if not subs:
|
||||
return
|
||||
now = datetime.now(UTC)
|
||||
values = [
|
||||
{
|
||||
'telegram_id': telegram_id,
|
||||
'channel_id': channel_id,
|
||||
'is_member': is_member,
|
||||
'checked_at': now,
|
||||
}
|
||||
for channel_id, is_member in subs.items()
|
||||
]
|
||||
stmt = pg_insert(UserChannelSubscription).values(values)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
constraint='uq_user_channel_sub',
|
||||
set_={
|
||||
'is_member': stmt.excluded.is_member,
|
||||
'checked_at': stmt.excluded.checked_at,
|
||||
},
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user