Compare commits
108 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 |
+8
-5
@@ -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 # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
|
||||
|
||||
@@ -632,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
|
||||
@@ -805,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
|
||||
|
||||
|
||||
@@ -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.16.0"
|
||||
".": "3.20.1"
|
||||
}
|
||||
|
||||
+149
@@ -1,5 +1,154 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.16.0" # x-release-please-version
|
||||
ARG VERSION="v3.20.1" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
-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": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+12
-8
@@ -45,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,
|
||||
@@ -58,10 +59,12 @@ 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
|
||||
@@ -115,6 +118,9 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
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())
|
||||
@@ -135,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())
|
||||
@@ -194,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)
|
||||
|
||||
@@ -11,13 +11,23 @@ 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
|
||||
@@ -36,6 +46,14 @@ def create_access_token(user_id: int, telegram_id: int | None = None) -> str:
|
||||
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)
|
||||
|
||||
|
||||
+189
-53
@@ -1,10 +1,7 @@
|
||||
"""FastAPI dependencies for cabinet module."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from fastapi import Depends, HTTPException, status
|
||||
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 = 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,47 +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('Timeout checking channel subscription for user', telegram_id=user.telegram_id)
|
||||
# Don't block user if check times out
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e
|
||||
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,
|
||||
},
|
||||
)
|
||||
# Don't block user if check fails
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_cabinet_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> User | None:
|
||||
@@ -200,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,18 +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
|
||||
@@ -79,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)
|
||||
@@ -101,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,7 +1,6 @@
|
||||
"""Admin routes for managing VPN applications in app-config.json."""
|
||||
"""Admin routes for managing RemnaWave app configuration."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -13,7 +12,7 @@ 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 = structlog.get_logger(__name__)
|
||||
@@ -24,431 +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('Admin created app for platform', admin_id=admin.id, app_id=request.app.id, 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('Admin updated app in platform', admin_id=admin.id, app_id=app_id, 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('Admin deleted app from platform', admin_id=admin.id, app_id=app_id, 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('Admin reordered apps in platform', admin_id=admin.id, 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('Admin updated branding', admin_id=admin.id)
|
||||
|
||||
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(
|
||||
'Admin copied app from to as',
|
||||
admin_id=admin.id,
|
||||
app_id=app_id,
|
||||
platform=platform,
|
||||
target_platform=target_platform,
|
||||
new_id=new_id,
|
||||
)
|
||||
|
||||
return {'status': 'copied', 'new_id': new_id, 'target_platform': target_platform}
|
||||
|
||||
|
||||
# ============ RemnaWave Config Routes ============
|
||||
|
||||
|
||||
class RemnaWaveConfigStatus(BaseModel):
|
||||
"""Status of RemnaWave config integration."""
|
||||
|
||||
@@ -462,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:
|
||||
@@ -470,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()
|
||||
@@ -485,27 +67,26 @@ 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('Admin updated CABINET_REMNA_SUB_CONFIG to', admin_id=admin.id, uuid_value=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('Error saving RemnaWave config UUID', error=e)
|
||||
raise HTTPException(
|
||||
@@ -521,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:
|
||||
@@ -541,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,
|
||||
@@ -557,13 +134,13 @@ async def get_remnawave_subscription_config(
|
||||
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:
|
||||
@@ -582,5 +159,5 @@ async def list_remnawave_subscription_configs(
|
||||
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}"'},
|
||||
)
|
||||
@@ -9,7 +9,7 @@ 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,
|
||||
@@ -103,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(
|
||||
@@ -117,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()
|
||||
@@ -127,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
|
||||
@@ -181,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()
|
||||
@@ -211,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()
|
||||
@@ -241,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()
|
||||
@@ -272,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()
|
||||
@@ -325,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()
|
||||
@@ -360,7 +360,7 @@ 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()
|
||||
@@ -377,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()
|
||||
@@ -401,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()
|
||||
@@ -438,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()
|
||||
@@ -480,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()
|
||||
@@ -579,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()
|
||||
@@ -603,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()
|
||||
@@ -637,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()
|
||||
@@ -681,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()
|
||||
@@ -744,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()
|
||||
@@ -802,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()
|
||||
@@ -815,7 +815,7 @@ 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()
|
||||
@@ -829,7 +829,7 @@ 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()
|
||||
@@ -846,7 +846,7 @@ 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()
|
||||
@@ -863,7 +863,7 @@ 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()
|
||||
@@ -883,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()
|
||||
@@ -913,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()
|
||||
@@ -947,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()
|
||||
@@ -967,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()
|
||||
@@ -1003,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()
|
||||
|
||||
@@ -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,
|
||||
@@ -247,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."""
|
||||
@@ -310,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."""
|
||||
@@ -333,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)
|
||||
@@ -352,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."""
|
||||
@@ -381,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."""
|
||||
@@ -461,7 +461,7 @@ async def create_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),
|
||||
@@ -487,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."""
|
||||
@@ -523,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."""
|
||||
@@ -548,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)."""
|
||||
@@ -679,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."""
|
||||
@@ -695,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)."""
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 = structlog.get_logger(__name__)
|
||||
@@ -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."""
|
||||
@@ -243,7 +243,7 @@ async def update_button_styles(
|
||||
|
||||
@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."""
|
||||
|
||||
@@ -30,7 +30,7 @@ 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 ..schemas.campaigns import (
|
||||
AvailablePartnerItem,
|
||||
CampaignCreateRequest,
|
||||
@@ -80,7 +80,7 @@ def _get_partner_name(campaign: AdvertisingCampaign) -> str | None:
|
||||
|
||||
@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."""
|
||||
@@ -108,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."""
|
||||
@@ -126,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."""
|
||||
@@ -155,7 +155,7 @@ async def get_available_tariffs(
|
||||
|
||||
@router.get('/available-partners', response_model=list[AvailablePartnerItem])
|
||||
async def get_available_partners(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('campaigns:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of approved partners for campaign partner selector."""
|
||||
@@ -178,7 +178,7 @@ 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."""
|
||||
@@ -211,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."""
|
||||
@@ -257,7 +257,7 @@ async def get_campaign(
|
||||
@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."""
|
||||
@@ -303,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."""
|
||||
@@ -381,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."""
|
||||
@@ -446,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."""
|
||||
@@ -532,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."""
|
||||
@@ -560,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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -10,7 +10,7 @@ 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,
|
||||
@@ -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."""
|
||||
@@ -515,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."""
|
||||
@@ -543,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]
|
||||
@@ -588,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."""
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.database.models import (
|
||||
from app.services.partner_application_service import partner_application_service
|
||||
from app.services.partner_stats_service import PartnerStatsService
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.partners import (
|
||||
AdminApproveRequest,
|
||||
AdminPartnerApplicationItem,
|
||||
@@ -73,7 +73,7 @@ def _build_partner_settings_response() -> PartnerSettingsResponse:
|
||||
|
||||
@router.get('/settings', response_model=PartnerSettingsResponse)
|
||||
async def get_partner_settings(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:settings')),
|
||||
):
|
||||
"""Get partner system settings."""
|
||||
return _build_partner_settings_response()
|
||||
@@ -82,7 +82,7 @@ async def get_partner_settings(
|
||||
@router.patch('/settings', response_model=PartnerSettingsResponse)
|
||||
async def update_partner_settings(
|
||||
request: PartnerSettingsUpdateRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:settings')),
|
||||
):
|
||||
"""Update partner system settings."""
|
||||
from pathlib import Path
|
||||
@@ -159,7 +159,7 @@ 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(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List partner applications."""
|
||||
@@ -205,7 +205,7 @@ async def list_applications(
|
||||
async def approve_application(
|
||||
application_id: int,
|
||||
request: AdminApproveRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a partner application."""
|
||||
@@ -259,7 +259,7 @@ async def approve_application(
|
||||
async def reject_application(
|
||||
application_id: int,
|
||||
request: AdminRejectRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a partner application."""
|
||||
@@ -310,7 +310,7 @@ async def reject_application(
|
||||
|
||||
@router.get('/stats')
|
||||
async def get_partner_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get overall partner statistics."""
|
||||
@@ -340,7 +340,7 @@ async def get_partner_stats(
|
||||
async def list_partners(
|
||||
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('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List approved partners."""
|
||||
@@ -404,7 +404,7 @@ async def list_partners(
|
||||
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
|
||||
async def get_partner_detail(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed partner info."""
|
||||
@@ -460,7 +460,7 @@ async def get_partner_detail(
|
||||
async def update_commission(
|
||||
user_id: int,
|
||||
request: AdminUpdateCommissionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update partner commission percent."""
|
||||
@@ -495,7 +495,7 @@ async def update_commission(
|
||||
@router.post('/{user_id}/revoke')
|
||||
async def revoke_partner(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:revoke')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Revoke partner status."""
|
||||
@@ -514,7 +514,7 @@ async def revoke_partner(
|
||||
async def assign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Assign a campaign to a partner."""
|
||||
@@ -558,7 +558,7 @@ async def assign_campaign(
|
||||
async def unassign_campaign(
|
||||
user_id: int,
|
||||
campaign_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('partners:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unassign a campaign from a partner."""
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 = structlog.get_logger(__name__)
|
||||
@@ -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,7 +163,7 @@ 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."""
|
||||
@@ -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."""
|
||||
|
||||
@@ -19,7 +19,7 @@ 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 = structlog.get_logger(__name__)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
@@ -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:
|
||||
"""
|
||||
@@ -201,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."""
|
||||
@@ -240,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."""
|
||||
@@ -267,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."""
|
||||
@@ -282,7 +282,7 @@ async def deactivate_active_message(
|
||||
|
||||
@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."""
|
||||
@@ -311,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:
|
||||
"""
|
||||
@@ -360,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."""
|
||||
@@ -391,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."""
|
||||
|
||||
@@ -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}
|
||||
@@ -34,7 +34,7 @@ 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 = structlog.get_logger(__name__)
|
||||
@@ -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),
|
||||
@@ -491,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."""
|
||||
@@ -605,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'])
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -153,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()
|
||||
@@ -176,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()
|
||||
@@ -238,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()
|
||||
@@ -252,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()
|
||||
@@ -278,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()
|
||||
@@ -290,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()
|
||||
@@ -309,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()
|
||||
@@ -335,7 +335,7 @@ 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()
|
||||
@@ -358,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()
|
||||
@@ -399,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()
|
||||
@@ -421,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."""
|
||||
@@ -463,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."""
|
||||
@@ -506,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()
|
||||
@@ -533,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()
|
||||
@@ -564,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()
|
||||
@@ -609,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()
|
||||
@@ -632,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."""
|
||||
@@ -657,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."""
|
||||
@@ -731,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()
|
||||
@@ -746,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:
|
||||
@@ -775,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:
|
||||
@@ -811,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:
|
||||
@@ -839,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."""
|
||||
@@ -863,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."""
|
||||
@@ -882,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."""
|
||||
@@ -925,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."""
|
||||
@@ -944,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."""
|
||||
@@ -963,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."""
|
||||
@@ -982,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}
|
||||
@@ -16,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,
|
||||
@@ -66,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."""
|
||||
@@ -103,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."""
|
||||
@@ -146,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."""
|
||||
@@ -191,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."""
|
||||
@@ -218,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."""
|
||||
@@ -245,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."""
|
||||
@@ -287,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."""
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 = structlog.get_logger(__name__)
|
||||
@@ -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."""
|
||||
@@ -255,7 +255,7 @@ async def update_setting(
|
||||
@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."""
|
||||
|
||||
@@ -26,7 +26,7 @@ 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 = structlog.get_logger(__name__)
|
||||
@@ -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."""
|
||||
@@ -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."""
|
||||
@@ -358,7 +358,7 @@ 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:
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -596,7 +596,7 @@ 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."""
|
||||
@@ -812,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."""
|
||||
@@ -869,7 +869,7 @@ 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."""
|
||||
|
||||
@@ -19,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,
|
||||
@@ -107,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."""
|
||||
@@ -141,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."""
|
||||
@@ -161,7 +161,7 @@ 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."""
|
||||
@@ -176,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."""
|
||||
@@ -246,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."""
|
||||
@@ -307,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."""
|
||||
@@ -400,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."""
|
||||
@@ -430,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."""
|
||||
@@ -460,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.
|
||||
@@ -500,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."""
|
||||
|
||||
@@ -16,7 +16,7 @@ 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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
@@ -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."""
|
||||
@@ -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."""
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
@@ -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."""
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 = structlog.get_logger(__name__)
|
||||
@@ -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
|
||||
|
||||
@@ -37,7 +37,7 @@ from app.database.models import (
|
||||
)
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.users import (
|
||||
DeleteDeviceResponse,
|
||||
DeleteUserRequest,
|
||||
@@ -297,7 +297,10 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
try:
|
||||
await api.update_user(**update_kwargs)
|
||||
updated_panel_user = await api.update_user(**update_kwargs)
|
||||
subscription.subscription_url = updated_panel_user.subscription_url
|
||||
subscription.subscription_crypto_link = updated_panel_user.happ_crypto_link
|
||||
subscription.remnawave_short_uuid = updated_panel_user.short_uuid
|
||||
changes['action'] = 'updated'
|
||||
logger.info('Updated user in Remnawave panel', user_id=user.id)
|
||||
except Exception as update_error:
|
||||
@@ -326,6 +329,7 @@ async def _sync_subscription_to_panel(db: AsyncSession, user: User, subscription
|
||||
user.remnawave_uuid = new_panel_user.uuid
|
||||
subscription.remnawave_short_uuid = new_panel_user.short_uuid
|
||||
subscription.subscription_url = new_panel_user.subscription_url
|
||||
subscription.subscription_crypto_link = new_panel_user.happ_crypto_link
|
||||
changes['action'] = 'created'
|
||||
changes['panel_uuid'] = new_panel_user.uuid
|
||||
logger.info('Created user in Remnawave panel', user_id=user.id, uuid=new_panel_user.uuid)
|
||||
@@ -351,7 +355,7 @@ async def list_users(
|
||||
email: str | None = Query(None, max_length=255),
|
||||
status: UserStatusEnum | None = Query(None),
|
||||
sort_by: SortByEnum = Query(SortByEnum.CREATED_AT),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -408,7 +412,7 @@ async def list_users(
|
||||
|
||||
@router.get('/stats', response_model=UsersStatsResponse)
|
||||
async def get_users_stats(
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get overall users statistics."""
|
||||
@@ -509,7 +513,7 @@ async def get_users_stats(
|
||||
@router.get('/{user_id}', response_model=UserDetailResponse)
|
||||
async def get_user_detail(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed user information by ID."""
|
||||
@@ -575,12 +579,14 @@ async def get_user_detail(
|
||||
transactions_result = await db.execute(transactions_q)
|
||||
transactions = transactions_result.scalars().all()
|
||||
|
||||
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
|
||||
|
||||
recent_transactions = [
|
||||
UserTransactionItem(
|
||||
id=t.id,
|
||||
type=t.type,
|
||||
amount_kopeks=t.amount_kopeks,
|
||||
amount_rubles=t.amount_kopeks / 100,
|
||||
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
|
||||
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
|
||||
description=t.description,
|
||||
payment_method=t.payment_method,
|
||||
is_completed=t.is_completed,
|
||||
@@ -638,7 +644,7 @@ async def get_user_detail(
|
||||
@router.get('/by-telegram/{telegram_id}', response_model=UserDetailResponse)
|
||||
async def get_user_by_telegram(
|
||||
telegram_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user by Telegram ID."""
|
||||
@@ -657,7 +663,7 @@ async def get_user_by_telegram(
|
||||
@router.get('/{user_id}/panel-info', response_model=UserPanelInfoResponse)
|
||||
async def get_user_panel_info(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user panel info from Remnawave (config links, traffic, connection data)."""
|
||||
@@ -735,7 +741,7 @@ async def get_user_panel_info(
|
||||
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
|
||||
async def get_user_node_usage(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user per-node traffic usage (always 30 days with daily breakdown)."""
|
||||
@@ -823,7 +829,7 @@ async def get_user_node_usage(
|
||||
async def update_user_balance(
|
||||
user_id: int,
|
||||
request: UpdateBalanceRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:balance')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -900,7 +906,7 @@ async def update_user_balance(
|
||||
async def update_user_subscription(
|
||||
user_id: int,
|
||||
request: UpdateSubscriptionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:subscription')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1267,7 +1273,7 @@ async def update_user_subscription(
|
||||
async def get_user_available_tariffs(
|
||||
user_id: int,
|
||||
include_inactive: bool = Query(False, description='Include inactive tariffs'),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1365,7 +1371,7 @@ async def get_user_available_tariffs(
|
||||
async def update_user_status(
|
||||
user_id: int,
|
||||
request: UpdateUserStatusRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user status (active, blocked, deleted)."""
|
||||
@@ -1410,7 +1416,7 @@ async def update_user_status(
|
||||
async def block_user(
|
||||
user_id: int,
|
||||
reason: str | None = None,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:block')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Block a user (shortcut for status update)."""
|
||||
@@ -1421,7 +1427,7 @@ async def block_user(
|
||||
@router.post('/{user_id}/unblock', response_model=UpdateUserStatusResponse)
|
||||
async def unblock_user(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:block')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Unblock a user (shortcut for status update)."""
|
||||
@@ -1436,7 +1442,7 @@ async def unblock_user(
|
||||
async def update_user_restrictions(
|
||||
user_id: int,
|
||||
request: UpdateRestrictionsRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user restrictions (topup, subscription)."""
|
||||
@@ -1484,7 +1490,7 @@ async def update_user_restrictions(
|
||||
async def update_user_promo_group(
|
||||
user_id: int,
|
||||
request: UpdatePromoGroupRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:promo_group')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user promo group."""
|
||||
@@ -1539,7 +1545,7 @@ async def update_user_promo_group(
|
||||
async def update_user_referral_commission(
|
||||
user_id: int,
|
||||
request: UpdateReferralCommissionRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:referral')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's individual referral commission percentage."""
|
||||
@@ -1577,7 +1583,7 @@ async def update_user_referral_commission(
|
||||
@router.get('/{user_id}/devices', response_model=UserDevicesResponse)
|
||||
async def get_user_devices(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user devices from Remnawave panel."""
|
||||
@@ -1631,7 +1637,7 @@ async def get_user_devices(
|
||||
async def delete_user_device(
|
||||
user_id: int,
|
||||
hwid: str,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Delete a single device for user."""
|
||||
@@ -1662,7 +1668,7 @@ async def delete_user_device(
|
||||
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
|
||||
async def reset_user_devices(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reset all devices for user."""
|
||||
@@ -1710,7 +1716,7 @@ async def reset_user_devices(
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
request: DeleteUserRequest = DeleteUserRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1748,7 +1754,7 @@ async def delete_user(
|
||||
async def full_delete_user(
|
||||
user_id: int,
|
||||
request: FullDeleteUserRequest = FullDeleteUserRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:delete')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1771,15 +1777,18 @@ async def full_delete_user(
|
||||
panel_error: str | None = None
|
||||
deleted_from_panel = False
|
||||
|
||||
# Pre-fetch admin.id to avoid MissingGreenlet after transaction rollback
|
||||
admin_id_val = admin.id
|
||||
|
||||
# UserService.delete_user_account handles both bot DB and Remnawave panel
|
||||
user_service = UserService()
|
||||
success = await user_service.delete_user_account(db, user_id, admin.id)
|
||||
success = await user_service.delete_user_account(db, user_id, admin_id_val)
|
||||
|
||||
if success:
|
||||
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info('Admin fully deleted user', admin_id=admin.id, user_id=user_id, reason_text=reason_text)
|
||||
logger.info('Admin fully deleted user', admin_id=admin_id_val, user_id=user_id, reason_text=reason_text)
|
||||
|
||||
return FullDeleteUserResponse(
|
||||
success=success,
|
||||
@@ -1794,7 +1803,7 @@ async def full_delete_user(
|
||||
async def reset_user_trial(
|
||||
user_id: int,
|
||||
request: ResetTrialRequest = ResetTrialRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:subscription')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1816,24 +1825,33 @@ async def reset_user_trial(
|
||||
|
||||
# Delete subscription if exists
|
||||
if user.subscription:
|
||||
# Deactivate in Remnawave panel first
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.database.crud.subscription import is_active_paid_subscription
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
|
||||
if is_active_paid_subscription(user.subscription):
|
||||
logger.info(
|
||||
'⏭️ Пропуск удаления подписки и RemnaWave: у пользователя активная оплаченная подписка',
|
||||
user_id=user_id,
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
else:
|
||||
# Deactivate in Remnawave panel first
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info('Disabled Remnawave user for trial reset', remnawave_uuid=user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to disable Remnawave user during trial reset', error=e)
|
||||
|
||||
subscription_id = user.subscription.id
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
# Delete subscription from database
|
||||
from sqlalchemy import delete
|
||||
|
||||
subscription_id = user.subscription.id
|
||||
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
|
||||
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
subscription_deleted = True
|
||||
|
||||
# Reset trial flag
|
||||
user.has_used_trial = False
|
||||
@@ -1856,7 +1874,7 @@ async def reset_user_trial(
|
||||
async def reset_user_subscription(
|
||||
user_id: int,
|
||||
request: ResetSubscriptionRequest = ResetSubscriptionRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:subscription')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1886,6 +1904,21 @@ async def reset_user_subscription(
|
||||
panel_deactivated=False,
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import is_active_paid_subscription
|
||||
|
||||
if is_active_paid_subscription(user.subscription):
|
||||
logger.info(
|
||||
'⏭️ Пропуск сброса подписки: у пользователя активная оплаченная подписка',
|
||||
user_id=user_id,
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
return ResetSubscriptionResponse(
|
||||
success=False,
|
||||
message='Cannot reset active paid subscription. Subscription is still active and paid.',
|
||||
subscription_deleted=False,
|
||||
panel_deactivated=False,
|
||||
)
|
||||
|
||||
# Deactivate in Remnawave panel if requested
|
||||
if request.deactivate_in_panel and user.remnawave_uuid:
|
||||
try:
|
||||
@@ -1926,7 +1959,7 @@ async def reset_user_subscription(
|
||||
async def disable_user(
|
||||
user_id: int,
|
||||
request: DisableUserRequest = DisableUserRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:block')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -1948,8 +1981,16 @@ async def disable_user(
|
||||
panel_deactivated = False
|
||||
panel_error: str | None = None
|
||||
|
||||
# Deactivate subscription in panel
|
||||
if user.remnawave_uuid:
|
||||
# Deactivate subscription in panel (skip if active paid subscription)
|
||||
from app.database.crud.subscription import is_active_paid_subscription
|
||||
|
||||
if is_active_paid_subscription(user.subscription):
|
||||
logger.info(
|
||||
'⏭️ Пропуск отключения RemnaWave: у пользователя активная оплаченная подписка',
|
||||
user_id=user_id,
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
elif user.remnawave_uuid:
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
@@ -1961,8 +2002,8 @@ async def disable_user(
|
||||
panel_error = str(e)
|
||||
logger.warning('Failed to disable Remnawave user', error=e)
|
||||
|
||||
# Deactivate subscription in bot database
|
||||
if user.subscription:
|
||||
# Deactivate subscription in bot database (skip if active paid subscription)
|
||||
if user.subscription and not is_active_paid_subscription(user.subscription):
|
||||
from app.database.crud.subscription import deactivate_subscription
|
||||
|
||||
await deactivate_subscription(db, user.subscription)
|
||||
@@ -1995,7 +2036,7 @@ async def get_user_referrals(
|
||||
user_id: int,
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get list of users referred by this user."""
|
||||
@@ -2035,7 +2076,7 @@ async def get_user_transactions(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
transaction_type: str | None = Query(None),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user transactions."""
|
||||
@@ -2062,12 +2103,14 @@ async def get_user_transactions(
|
||||
result = await db.execute(query)
|
||||
transactions = result.scalars().all()
|
||||
|
||||
_EXPENSE_TYPES = {TransactionType.WITHDRAWAL.value, TransactionType.SUBSCRIPTION_PAYMENT.value}
|
||||
|
||||
items = [
|
||||
UserTransactionItem(
|
||||
id=t.id,
|
||||
type=t.type,
|
||||
amount_kopeks=t.amount_kopeks,
|
||||
amount_rubles=t.amount_kopeks / 100,
|
||||
amount_kopeks=-t.amount_kopeks if t.type in _EXPENSE_TYPES else t.amount_kopeks,
|
||||
amount_rubles=-t.amount_kopeks / 100 if t.type in _EXPENSE_TYPES else t.amount_kopeks / 100,
|
||||
description=t.description,
|
||||
payment_method=t.payment_method,
|
||||
is_completed=t.is_completed,
|
||||
@@ -2090,7 +2133,7 @@ async def get_user_transactions(
|
||||
@router.get('/{user_id}/sync/status', response_model=PanelSyncStatusResponse)
|
||||
async def get_user_sync_status(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -2247,7 +2290,7 @@ async def get_user_sync_status(
|
||||
async def sync_user_from_panel(
|
||||
user_id: int,
|
||||
request: SyncFromPanelRequest = SyncFromPanelRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
@@ -2449,7 +2492,7 @@ async def sync_user_from_panel(
|
||||
async def sync_user_to_panel(
|
||||
user_id: int,
|
||||
request: SyncToPanelRequest = SyncToPanelRequest(),
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('users:sync')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ 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,
|
||||
@@ -42,7 +42,7 @@ 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),
|
||||
):
|
||||
"""Обновить конфигурацию колеса."""
|
||||
@@ -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),
|
||||
):
|
||||
"""Создать новый приз."""
|
||||
@@ -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),
|
||||
):
|
||||
"""Обновить приз."""
|
||||
@@ -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),
|
||||
):
|
||||
"""Удалить приз."""
|
||||
@@ -304,7 +304,7 @@ async def delete_prize_endpoint(
|
||||
@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),
|
||||
):
|
||||
"""Переупорядочить призы."""
|
||||
@@ -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),
|
||||
):
|
||||
"""Получить все спины с фильтрами."""
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.database.models import (
|
||||
)
|
||||
from app.services.referral_withdrawal_service import referral_withdrawal_service
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.withdrawals import (
|
||||
AdminApproveWithdrawalRequest,
|
||||
AdminRejectWithdrawalRequest,
|
||||
@@ -49,7 +49,7 @@ async def list_withdrawals(
|
||||
),
|
||||
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('withdrawals:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""List all withdrawal requests."""
|
||||
@@ -123,7 +123,7 @@ async def list_withdrawals(
|
||||
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
|
||||
async def get_withdrawal_detail(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('withdrawals:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get detailed withdrawal request with risk analysis."""
|
||||
@@ -180,7 +180,7 @@ async def get_withdrawal_detail(
|
||||
async def approve_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminApproveWithdrawalRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('withdrawals:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Approve a withdrawal request."""
|
||||
@@ -232,7 +232,7 @@ async def approve_withdrawal(
|
||||
async def reject_withdrawal(
|
||||
withdrawal_id: int,
|
||||
request: AdminRejectWithdrawalRequest,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('withdrawals:reject')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Reject a withdrawal request."""
|
||||
@@ -283,7 +283,7 @@ async def reject_withdrawal(
|
||||
@router.post('/{withdrawal_id}/complete')
|
||||
async def complete_withdrawal(
|
||||
withdrawal_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
admin: User = Depends(require_permission('withdrawals:approve')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Mark a withdrawal as completed (money transferred)."""
|
||||
|
||||
+107
-10
@@ -15,6 +15,7 @@ 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,
|
||||
@@ -99,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
|
||||
|
||||
@@ -151,6 +160,15 @@ async def _process_campaign_bonus(
|
||||
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())
|
||||
|
||||
@@ -200,6 +218,30 @@ async def _process_campaign_bonus(
|
||||
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:
|
||||
"""
|
||||
Check if user has subscription in RemnaWave panel by email and sync it.
|
||||
@@ -341,6 +383,16 @@ 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('Creating new user from cabinet (initData): telegram_id', telegram_id=telegram_id)
|
||||
@@ -351,6 +403,7 @@ async def auth_telegram(
|
||||
first_name=tg_first_name,
|
||||
last_name=tg_last_name,
|
||||
language=tg_language,
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
|
||||
else:
|
||||
@@ -378,11 +431,14 @@ async def auth_telegram(
|
||||
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:
|
||||
@@ -402,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(exclude={'campaign_slug'})
|
||||
widget_data = request.model_dump(exclude={'campaign_slug', 'referral_code'})
|
||||
|
||||
if not validate_telegram_login_widget(widget_data):
|
||||
raise HTTPException(
|
||||
@@ -412,6 +468,16 @@ 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(
|
||||
@@ -424,6 +490,7 @@ async def auth_telegram_widget(
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
language='ru',
|
||||
referred_by_id=referrer_id,
|
||||
)
|
||||
logger.info('User created successfully: id=, telegram_id', user_id=user.id, telegram_id=user.telegram_id)
|
||||
|
||||
@@ -444,9 +511,12 @@ async def auth_telegram_widget(
|
||||
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:
|
||||
@@ -716,7 +786,7 @@ 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
|
||||
@@ -863,7 +933,7 @@ async def login_email(
|
||||
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
|
||||
@@ -926,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(
|
||||
@@ -1050,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}
|
||||
|
||||
|
||||
|
||||
+174
-13
@@ -3,18 +3,19 @@
|
||||
import json
|
||||
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 = structlog.get_logger(__name__)
|
||||
@@ -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)."""
|
||||
@@ -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."""
|
||||
@@ -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."""
|
||||
@@ -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."""
|
||||
@@ -493,7 +591,7 @@ async def update_theme_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."""
|
||||
@@ -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."""
|
||||
@@ -587,7 +685,7 @@ 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."""
|
||||
@@ -598,6 +696,69 @@ async def update_animation_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,7 +783,7 @@ 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."""
|
||||
@@ -658,7 +819,7 @@ 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."""
|
||||
@@ -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."""
|
||||
@@ -758,7 +919,7 @@ 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."""
|
||||
|
||||
@@ -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
|
||||
@@ -37,16 +38,21 @@ async def _finalize_oauth_login(
|
||||
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)
|
||||
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:
|
||||
from .auth import _user_to_response
|
||||
|
||||
auth_response.user = _user_to_response(user)
|
||||
return auth_response
|
||||
|
||||
@@ -74,6 +80,7 @@ class OAuthCallbackRequest(BaseModel):
|
||||
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 ---
|
||||
@@ -153,7 +160,7 @@ async def oauth_callback(
|
||||
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
|
||||
if user:
|
||||
logger.info('OAuth login via for existing user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
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:
|
||||
@@ -161,9 +168,32 @@ async def oauth_callback(
|
||||
if user:
|
||||
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
|
||||
logger.info('OAuth login via linked to existing email user', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
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,
|
||||
@@ -173,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 with id', provider=provider, user_id=user.id)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug)
|
||||
return await _finalize_oauth_login(db, user, provider, request.campaign_slug, request.referral_code)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Subscription management routes for cabinet."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -205,7 +204,10 @@ def _subscription_to_response(
|
||||
if is_daily and not is_daily_paused:
|
||||
last_charge = getattr(subscription, 'last_daily_charge_at', None)
|
||||
if last_charge:
|
||||
next_daily_charge_at = last_charge + timedelta(days=1)
|
||||
next_charge = last_charge + timedelta(days=1)
|
||||
# Если время списания уже прошло — не показываем (DailySubscriptionService обработает)
|
||||
if next_charge > datetime.now(UTC):
|
||||
next_daily_charge_at = next_charge
|
||||
|
||||
# Проверяем настройку скрытия ссылки (скрывается только текст, кнопки работают)
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
@@ -2681,19 +2683,6 @@ async def get_device_price(
|
||||
# ============ App Config for Connection ============
|
||||
|
||||
|
||||
def _load_app_config_from_file() -> dict[str, Any]:
|
||||
"""Load app-config.json file."""
|
||||
try:
|
||||
config_path = settings.get_app_config_path()
|
||||
with open(config_path, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error('Failed to load app-config.json', error=e)
|
||||
return {}
|
||||
|
||||
|
||||
def _get_remnawave_config_uuid() -> str | None:
|
||||
"""Get RemnaWave config UUID from system settings or env."""
|
||||
try:
|
||||
@@ -2702,58 +2691,6 @@ def _get_remnawave_config_uuid() -> str | None:
|
||||
return settings.CABINET_REMNA_SUB_CONFIG
|
||||
|
||||
|
||||
def _is_subscription_link_template(url: str) -> bool:
|
||||
"""Check if URL is a RemnaWave subscription link template."""
|
||||
if not url:
|
||||
return False
|
||||
# RemnaWave uses templates like {{HAPP_CRYPT4_LINK}}, {{V2RAY_LINK}}, etc.
|
||||
if url.startswith('{{') and url.endswith('}}'):
|
||||
return True
|
||||
# Also check for button type "subscriptionLink" indicator
|
||||
return False
|
||||
|
||||
|
||||
def _convert_remnawave_block_to_step(block: dict[str, Any], url_scheme: str = '') -> dict[str, Any]:
|
||||
"""Convert RemnaWave block format to cabinet step format."""
|
||||
step = {
|
||||
'description': block.get('description', {}),
|
||||
}
|
||||
if block.get('title'):
|
||||
step['title'] = block['title']
|
||||
if block.get('buttons'):
|
||||
buttons = []
|
||||
for btn in block['buttons']:
|
||||
btn_url = btn.get('url', '') or btn.get('link', '')
|
||||
btn_type = btn.get('type', '')
|
||||
|
||||
# Replace subscription link templates with {{deepLink}} placeholder
|
||||
# RemnaWave uses templates like {{HAPP_CRYPT4_LINK}} or type="subscriptionLink"
|
||||
if (
|
||||
_is_subscription_link_template(btn_url)
|
||||
or btn_type == 'subscriptionLink'
|
||||
or (
|
||||
url_scheme
|
||||
and btn_url
|
||||
and (
|
||||
btn_url.startswith(url_scheme)
|
||||
or btn_url.endswith('://')
|
||||
or btn_url.endswith('://add/')
|
||||
or ('://' in btn_url and not btn_url.startswith('http'))
|
||||
)
|
||||
)
|
||||
):
|
||||
btn_url = '{{deepLink}}'
|
||||
|
||||
buttons.append(
|
||||
{
|
||||
'buttonLink': btn_url,
|
||||
'buttonText': btn.get('text', {}),
|
||||
}
|
||||
)
|
||||
step['buttons'] = buttons
|
||||
return step
|
||||
|
||||
|
||||
def _extract_scheme_from_buttons(buttons: list[dict[str, Any]]) -> tuple[str, bool]:
|
||||
"""Extract URL scheme from buttons list.
|
||||
|
||||
@@ -2822,15 +2759,6 @@ def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
|
||||
if scheme:
|
||||
return scheme, uses_crypto
|
||||
|
||||
# 4. Check in step structures (cabinet format)
|
||||
for step_key in ['installationStep', 'addSubscriptionStep', 'connectAndUseStep']:
|
||||
step = app.get(step_key, {})
|
||||
if isinstance(step, dict):
|
||||
step_buttons = step.get('buttons', [])
|
||||
scheme, uses_crypto = _extract_scheme_from_buttons(step_buttons)
|
||||
if scheme:
|
||||
return scheme, uses_crypto
|
||||
|
||||
# No scheme found
|
||||
logger.debug(
|
||||
'_get_url_scheme_for_app: No scheme found for app has blocks: has buttons: has urlScheme',
|
||||
@@ -2842,147 +2770,10 @@ def _get_url_scheme_for_app(app: dict[str, Any]) -> tuple[str, bool]:
|
||||
return '', False
|
||||
|
||||
|
||||
def _find_subscription_block(blocks: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""Find block that contains subscriptionLink button."""
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
buttons = block.get('buttons', [])
|
||||
for btn in buttons:
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
# Check for subscriptionLink type, {{SUBSCRIPTION_LINK}}, or {{HAPP_CRYPT4_LINK}} in link
|
||||
btn_type = btn.get('type', '')
|
||||
link = btn.get('link', '') or btn.get('url', '')
|
||||
link_upper = link.upper() if link else ''
|
||||
if btn_type == 'subscriptionLink' or 'SUBSCRIPTION_LINK' in link_upper or 'HAPP_CRYPT4_LINK' in link_upper:
|
||||
return block
|
||||
return None
|
||||
async def _load_app_config_async() -> dict[str, Any] | None:
|
||||
"""Load app config from RemnaWave API (if configured).
|
||||
|
||||
|
||||
def _find_connect_block(blocks: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""Find block that is about connection/usage (usually last or has specific keywords)."""
|
||||
# Look for block with "connect" or "use" in title
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
title = block.get('title', {})
|
||||
title_en = title.get('en', '') if isinstance(title, dict) else ''
|
||||
title_lower = title_en.lower()
|
||||
if 'connect' in title_lower or 'use' in title_lower:
|
||||
return block
|
||||
# Fallback to last block if no match
|
||||
return blocks[-1] if blocks else None
|
||||
|
||||
|
||||
def _convert_remnawave_app_to_cabinet(app: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert RemnaWave app format to cabinet app format."""
|
||||
blocks = app.get('blocks', [])
|
||||
url_scheme, uses_crypto = _get_url_scheme_for_app(app)
|
||||
|
||||
# Debug log for conversion (не логируем отсутствие urlScheme - для Happ это нормально)
|
||||
app_name = app.get('name', 'unknown')
|
||||
if url_scheme:
|
||||
logger.debug('_convert_remnawave_app_to_cabinet: app urlScheme', app_name=app_name, url_scheme=url_scheme)
|
||||
|
||||
# Smart block mapping: find blocks by their content, not just position
|
||||
# 1. First block is usually installation
|
||||
installation_block = blocks[0] if len(blocks) > 0 else None
|
||||
# 2. Find subscription block (with subscriptionLink button)
|
||||
subscription_block = _find_subscription_block(blocks)
|
||||
# 3. Find connect/use block (usually last or has "connect" in title)
|
||||
connect_block = _find_connect_block(blocks)
|
||||
|
||||
# Convert blocks to steps
|
||||
installation_step = (
|
||||
_convert_remnawave_block_to_step(installation_block, url_scheme) if installation_block else {'description': {}}
|
||||
)
|
||||
subscription_step = (
|
||||
_convert_remnawave_block_to_step(subscription_block, url_scheme) if subscription_block else {'description': {}}
|
||||
)
|
||||
connect_step = _convert_remnawave_block_to_step(connect_block, url_scheme) if connect_block else {'description': {}}
|
||||
|
||||
# Ensure subscription step has a deepLink button if urlScheme exists
|
||||
if url_scheme:
|
||||
has_deeplink_button = False
|
||||
if 'buttons' in subscription_step:
|
||||
for btn in subscription_step['buttons']:
|
||||
if btn.get('buttonLink') == '{{deepLink}}':
|
||||
has_deeplink_button = True
|
||||
break
|
||||
|
||||
if not has_deeplink_button:
|
||||
# Add deepLink button at the beginning
|
||||
deeplink_button = {
|
||||
'buttonLink': '{{deepLink}}',
|
||||
'buttonText': {
|
||||
'en': 'Open app',
|
||||
'ru': 'Открыть приложение',
|
||||
'zh': '打开应用',
|
||||
'fa': 'باز کردن برنامه',
|
||||
},
|
||||
}
|
||||
if 'buttons' not in subscription_step:
|
||||
subscription_step['buttons'] = []
|
||||
subscription_step['buttons'].insert(0, deeplink_button)
|
||||
|
||||
return {
|
||||
'id': app.get('name', '').lower().replace(' ', '-'),
|
||||
'name': app.get('name', ''),
|
||||
'isFeatured': app.get('featured', False),
|
||||
'urlScheme': url_scheme,
|
||||
'usesCryptoLink': uses_crypto,
|
||||
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
|
||||
'installationStep': installation_step,
|
||||
'addSubscriptionStep': subscription_step,
|
||||
'connectAndUseStep': connect_step,
|
||||
}
|
||||
|
||||
|
||||
def _convert_remnawave_config_to_cabinet(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert RemnaWave config format to cabinet format."""
|
||||
platforms = {}
|
||||
remnawave_platforms = config.get('platforms', {})
|
||||
|
||||
for platform_key, platform_data in remnawave_platforms.items():
|
||||
if not isinstance(platform_data, dict):
|
||||
continue
|
||||
apps = platform_data.get('apps', [])
|
||||
if not isinstance(apps, list):
|
||||
continue
|
||||
|
||||
cabinet_apps = []
|
||||
for app in apps:
|
||||
if isinstance(app, dict):
|
||||
cabinet_apps.append(_convert_remnawave_app_to_cabinet(app))
|
||||
|
||||
if cabinet_apps:
|
||||
platforms[platform_key] = cabinet_apps
|
||||
|
||||
# Convert branding
|
||||
branding = {}
|
||||
if config.get('brandingSettings'):
|
||||
branding = {
|
||||
'name': config['brandingSettings'].get('name', ''),
|
||||
'logoUrl': config['brandingSettings'].get('logoUrl', ''),
|
||||
'supportUrl': config['brandingSettings'].get('supportUrl', ''),
|
||||
}
|
||||
|
||||
return {
|
||||
'config': {
|
||||
'additionalLocales': ['zh', 'fa'],
|
||||
'branding': branding,
|
||||
},
|
||||
'platforms': platforms,
|
||||
}
|
||||
|
||||
|
||||
async def _load_app_config_async() -> dict[str, Any]:
|
||||
"""Load app config from RemnaWave (if configured) or local file.
|
||||
|
||||
When config comes from RemnaWave, returns the original format with
|
||||
``_isRemnawave`` flag so the caller can serve it as-is (enriched with
|
||||
deep links) instead of converting to the legacy step-based format.
|
||||
Returns None when no Remnawave config is set or API fails.
|
||||
"""
|
||||
remnawave_uuid = _get_remnawave_config_uuid()
|
||||
|
||||
@@ -2997,15 +2788,9 @@ async def _load_app_config_async() -> dict[str, Any]:
|
||||
raw['_isRemnawave'] = True
|
||||
return raw
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load RemnaWave config, falling back to file', error=e)
|
||||
logger.warning('Failed to load RemnaWave config', error=e)
|
||||
|
||||
# Fallback to local file
|
||||
return _load_app_config_from_file()
|
||||
|
||||
|
||||
def _load_app_config() -> dict[str, Any]:
|
||||
"""Load app-config.json file (sync version for compatibility)."""
|
||||
return _load_app_config_from_file()
|
||||
return None
|
||||
|
||||
|
||||
def _create_deep_link(
|
||||
@@ -3420,18 +3205,22 @@ async def get_app_config(
|
||||
subscription_url = user.subscription.subscription_url
|
||||
subscription_crypto_link = user.subscription.subscription_crypto_link
|
||||
|
||||
# Load config from RemnaWave (if configured) or local file
|
||||
config = await _load_app_config_async()
|
||||
|
||||
is_remnawave = config.pop('_isRemnawave', False)
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='App configuration not set up.',
|
||||
)
|
||||
|
||||
config.pop('_isRemnawave', None)
|
||||
hide_link = settings.should_hide_subscription_link()
|
||||
|
||||
# Строим platformNames из displayName каждой платформы RemnaWave
|
||||
# Build platformNames from displayName of each platform
|
||||
platform_names: dict[str, Any] = {}
|
||||
for pk, pd in config.get('platforms', {}).items():
|
||||
if isinstance(pd, dict) and 'displayName' in pd:
|
||||
platform_names[pk] = pd['displayName']
|
||||
# Фоллбэк для платформ без displayName (en достаточно)
|
||||
fallback_names = {
|
||||
'ios': {'en': 'iPhone/iPad'},
|
||||
'android': {'en': 'Android'},
|
||||
@@ -3445,111 +3234,63 @@ async def get_app_config(
|
||||
if k not in platform_names:
|
||||
platform_names[k] = v
|
||||
|
||||
if is_remnawave:
|
||||
# ── RemnaWave original format ──
|
||||
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
|
||||
platforms: dict[str, Any] = {}
|
||||
for platform_key, platform_data in config.get('platforms', {}).items():
|
||||
if not isinstance(platform_data, dict):
|
||||
continue
|
||||
apps = platform_data.get('apps', [])
|
||||
if not isinstance(apps, list):
|
||||
continue
|
||||
|
||||
enriched_apps = []
|
||||
for app in apps:
|
||||
if not isinstance(app, dict):
|
||||
continue
|
||||
|
||||
# Generate deep link
|
||||
deep_link = None
|
||||
if subscription_url or subscription_crypto_link:
|
||||
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
|
||||
app['deepLink'] = deep_link
|
||||
|
||||
# Resolve templates only for subscriptionLink and copyButton (not external)
|
||||
for block in app.get('blocks', []):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
for btn in block.get('buttons', []):
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
btn_type = btn.get('type', '')
|
||||
if btn_type in ('subscriptionLink', 'copyButton'):
|
||||
url = btn.get('url', '') or btn.get('link', '')
|
||||
if url and '{{' in url:
|
||||
btn['resolvedUrl'] = _resolve_button_url(
|
||||
url,
|
||||
subscription_url,
|
||||
subscription_crypto_link,
|
||||
)
|
||||
|
||||
enriched_apps.append(app)
|
||||
|
||||
if enriched_apps:
|
||||
# Сохраняем platform-level поля (svgIconKey, displayName и т.д.)
|
||||
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
|
||||
platform_output['apps'] = enriched_apps
|
||||
platforms[platform_key] = platform_output
|
||||
|
||||
return {
|
||||
'isRemnawave': True,
|
||||
'platforms': platforms,
|
||||
'svgLibrary': config.get('svgLibrary', {}),
|
||||
'baseTranslations': config.get('baseTranslations'),
|
||||
'baseSettings': config.get('baseSettings'),
|
||||
'uiConfig': config.get('uiConfig', {}),
|
||||
'platformNames': platform_names,
|
||||
'hasSubscription': bool(subscription_url or subscription_crypto_link),
|
||||
'subscriptionUrl': subscription_url,
|
||||
'subscriptionCryptoLink': subscription_crypto_link,
|
||||
'hideLink': hide_link,
|
||||
'branding': config.get('brandingSettings', {}),
|
||||
}
|
||||
|
||||
# ── Legacy file-based format ──
|
||||
platforms_raw = config.get('platforms', {})
|
||||
if not isinstance(platforms_raw, dict):
|
||||
platforms_raw = {}
|
||||
|
||||
platforms_legacy: dict[str, Any] = {}
|
||||
for platform_key, apps in platforms_raw.items():
|
||||
# Serve original blocks/svgLibrary enriched with deep links and resolved URLs.
|
||||
platforms: dict[str, Any] = {}
|
||||
for platform_key, platform_data in config.get('platforms', {}).items():
|
||||
if not isinstance(platform_data, dict):
|
||||
continue
|
||||
apps = platform_data.get('apps', [])
|
||||
if not isinstance(apps, list):
|
||||
continue
|
||||
|
||||
platform_apps = []
|
||||
enriched_apps = []
|
||||
for app in apps:
|
||||
if not isinstance(app, dict):
|
||||
continue
|
||||
|
||||
app_data = {
|
||||
'id': app.get('id'),
|
||||
'name': app.get('name'),
|
||||
'isFeatured': app.get('isFeatured', False),
|
||||
'installationStep': app.get('installationStep'),
|
||||
'addSubscriptionStep': app.get('addSubscriptionStep'),
|
||||
'connectAndUseStep': app.get('connectAndUseStep'),
|
||||
'additionalBeforeAddSubscriptionStep': app.get('additionalBeforeAddSubscriptionStep'),
|
||||
'additionalAfterAddSubscriptionStep': app.get('additionalAfterAddSubscriptionStep'),
|
||||
}
|
||||
|
||||
# Add deep link if subscription exists
|
||||
# Generate deep link
|
||||
deep_link = None
|
||||
if subscription_url or subscription_crypto_link:
|
||||
app_data['deepLink'] = _create_deep_link(app, subscription_url, subscription_crypto_link)
|
||||
deep_link = _create_deep_link(app, subscription_url, subscription_crypto_link)
|
||||
app['deepLink'] = deep_link
|
||||
|
||||
platform_apps.append(app_data)
|
||||
# Resolve templates only for subscriptionLink and copyButton (not external)
|
||||
for block in app.get('blocks', []):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
for btn in block.get('buttons', []):
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
btn_type = btn.get('type', '')
|
||||
if btn_type in ('subscriptionLink', 'copyButton'):
|
||||
url = btn.get('url', '') or btn.get('link', '')
|
||||
if url and '{{' in url:
|
||||
btn['resolvedUrl'] = _resolve_button_url(
|
||||
url,
|
||||
subscription_url,
|
||||
subscription_crypto_link,
|
||||
)
|
||||
|
||||
if platform_apps:
|
||||
platforms_legacy[platform_key] = platform_apps
|
||||
enriched_apps.append(app)
|
||||
|
||||
if enriched_apps:
|
||||
platform_output = {k: v for k, v in platform_data.items() if k != 'apps'}
|
||||
platform_output['apps'] = enriched_apps
|
||||
platforms[platform_key] = platform_output
|
||||
|
||||
return {
|
||||
'platforms': platforms_legacy,
|
||||
'isRemnawave': True,
|
||||
'platforms': platforms,
|
||||
'svgLibrary': config.get('svgLibrary', {}),
|
||||
'baseTranslations': config.get('baseTranslations'),
|
||||
'baseSettings': config.get('baseSettings'),
|
||||
'uiConfig': config.get('uiConfig', {}),
|
||||
'platformNames': platform_names,
|
||||
'hasSubscription': bool(subscription_url or subscription_crypto_link),
|
||||
'subscriptionUrl': subscription_url if not hide_link else None,
|
||||
'subscriptionCryptoLink': subscription_crypto_link if not hide_link else None,
|
||||
'subscriptionUrl': subscription_url,
|
||||
'subscriptionCryptoLink': subscription_crypto_link,
|
||||
'hideLink': hide_link,
|
||||
'branding': config.get('config', {}).get('branding', {}),
|
||||
'branding': config.get('brandingSettings', {}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 = structlog.get_logger(__name__)
|
||||
@@ -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."""
|
||||
|
||||
@@ -282,7 +282,13 @@ async def add_ticket_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('Error notifying admins about ticket reply from cabinet', error=e)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class TelegramAuthRequest(BaseModel):
|
||||
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):
|
||||
@@ -27,6 +28,7 @@ class TelegramWidgetAuthRequest(BaseModel):
|
||||
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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -15,7 +15,7 @@ class PartnerApplicationRequest(BaseModel):
|
||||
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)
|
||||
expected_monthly_referrals: int | None = Field(None, ge=0, le=2_000_000_000)
|
||||
|
||||
|
||||
class PartnerApplicationInfo(BaseModel):
|
||||
|
||||
@@ -86,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):
|
||||
@@ -101,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):
|
||||
@@ -137,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):
|
||||
@@ -156,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')
|
||||
|
||||
|
||||
+27
-12
@@ -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
|
||||
@@ -502,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
|
||||
@@ -676,7 +679,6 @@ class Settings(BaseSettings):
|
||||
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
|
||||
|
||||
@@ -1383,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
|
||||
|
||||
@@ -1535,8 +1530,8 @@ class Settings(BaseSettings):
|
||||
logger.warning('Некорректное значение DEVICES_SELECTION_DISABLED_AMOUNT', raw_value=raw_value)
|
||||
return None
|
||||
|
||||
if value < 0:
|
||||
return 0
|
||||
if value <= 0:
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
@@ -1760,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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -36,6 +36,18 @@ def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
|
||||
return elapsed < _WEBHOOK_GUARD_SECONDS
|
||||
|
||||
|
||||
def is_active_paid_subscription(subscription: Subscription | None) -> bool:
|
||||
"""Return True if subscription is active, paid (non-trial), and not expired."""
|
||||
if not subscription:
|
||||
return False
|
||||
return (
|
||||
not subscription.is_trial
|
||||
and subscription.status == SubscriptionStatus.ACTIVE.value
|
||||
and subscription.end_date is not None
|
||||
and subscription.end_date > datetime.now(UTC)
|
||||
)
|
||||
|
||||
|
||||
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
@@ -746,15 +758,23 @@ async def get_expiring_subscriptions(db: AsyncSession, days_before: int = 3) ->
|
||||
|
||||
|
||||
async def get_expired_subscriptions(db: AsyncSession) -> list[Subscription]:
|
||||
from app.database.models import Tariff
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(selectinload(Subscription.user))
|
||||
.outerjoin(Tariff, Subscription.tariff_id == Tariff.id)
|
||||
.options(selectinload(Subscription.user), selectinload(Subscription.tariff))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.status == SubscriptionStatus.ACTIVE.value,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.end_date <= datetime.now(UTC),
|
||||
# Не трогаем активные суточные подписки — ими управляет DailySubscriptionService
|
||||
~and_(
|
||||
Tariff.is_daily.is_(True),
|
||||
Subscription.is_daily_paused.is_(False),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -459,6 +459,19 @@ class TicketMessageCRUD:
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_first_message(db: AsyncSession, ticket_id: int) -> TicketMessage | None:
|
||||
"""Получить первое сообщение в тикете"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(TicketMessage.created_at)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_last_message(db: AsyncSession, ticket_id: int) -> TicketMessage | None:
|
||||
"""Получить последнее сообщение в тикете"""
|
||||
|
||||
@@ -1300,6 +1300,7 @@ async def create_user_by_oauth(
|
||||
last_name: str | None = None,
|
||||
username: str | None = None,
|
||||
language: str = 'ru',
|
||||
referred_by_id: int | None = None,
|
||||
) -> User:
|
||||
"""Create a new user via OAuth provider."""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
@@ -1319,6 +1320,7 @@ async def create_user_by_oauth(
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=sanitize_telegram_name(last_name) if last_name else None,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
|
||||
+383
-203
File diff suppressed because it is too large
Load Diff
Vendored
+22
-1
@@ -461,9 +461,19 @@ class RemnaWaveAPI:
|
||||
if active_internal_squads:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
logger.debug('Создание пользователя в панели', data=data)
|
||||
logger.info(
|
||||
'POST /api/users payload',
|
||||
username=data.get('username'),
|
||||
hwidDeviceLimit=data.get('hwidDeviceLimit'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
response = await self._make_request('POST', '/api/users', data)
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'POST /api/users response',
|
||||
uuid=user.uuid,
|
||||
response_hwidDeviceLimit=user.hwid_device_limit,
|
||||
)
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def get_user_by_uuid(self, uuid: str) -> RemnaWaveUser | None:
|
||||
@@ -553,8 +563,19 @@ class RemnaWaveAPI:
|
||||
if active_internal_squads is not None:
|
||||
data['activeInternalSquads'] = active_internal_squads
|
||||
|
||||
logger.info(
|
||||
'PATCH /api/users payload',
|
||||
uuid=uuid,
|
||||
hwidDeviceLimit=data.get('hwidDeviceLimit'),
|
||||
status=data.get('status'),
|
||||
)
|
||||
response = await self._make_request('PATCH', '/api/users', data)
|
||||
user = self._parse_user(response['response'])
|
||||
logger.info(
|
||||
'PATCH /api/users response',
|
||||
uuid=uuid,
|
||||
response_hwidDeviceLimit=user.hwid_device_limit,
|
||||
)
|
||||
return await self.enrich_user_with_happ_link(user)
|
||||
|
||||
async def delete_user(self, uuid: str) -> bool:
|
||||
|
||||
@@ -24,6 +24,7 @@ from . import (
|
||||
referrals,
|
||||
remnawave,
|
||||
reports,
|
||||
required_channels,
|
||||
rules,
|
||||
servers,
|
||||
statistics,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import datetime
|
||||
|
||||
import structlog
|
||||
@@ -154,7 +155,7 @@ async def create_backup_handler(callback: types.CallbackQuery, db_user: User, db
|
||||
)
|
||||
else:
|
||||
await progress_msg.edit_text(
|
||||
f'❌ <b>Ошибка создания бекапа</b>\n\n{message}',
|
||||
f'❌ <b>Ошибка создания бекапа</b>\n\n{html.escape(message)}',
|
||||
parse_mode='HTML',
|
||||
reply_markup=get_backup_main_keyboard(db_user.language),
|
||||
)
|
||||
@@ -431,11 +432,11 @@ async def handle_backup_file_upload(message: types.Message, db_user: User, db: A
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='✅ Восстановить', callback_data=f'backup_restore_uploaded_{temp_path.name}'
|
||||
text='✅ Восстановить', callback_data=f'backup_restore_execute_{temp_path.name}'
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text='🗑️ Очистить и восстановить',
|
||||
callback_data=f'backup_restore_uploaded_clear_{temp_path.name}',
|
||||
callback_data=f'backup_restore_clear_{temp_path.name}',
|
||||
),
|
||||
],
|
||||
[InlineKeyboardButton(text='❌ Отмена', callback_data='backup_panel')],
|
||||
|
||||
@@ -5,6 +5,7 @@ import time
|
||||
from collections.abc import Iterable
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Dispatcher, F, types
|
||||
from aiogram.filters import BaseFilter, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -32,6 +33,8 @@ from app.utils.currency_converter import currency_converter
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
CATEGORY_PAGE_SIZE = 10
|
||||
SETTINGS_PAGE_SIZE = 8
|
||||
SIMPLE_SUBSCRIPTION_SQUADS_PAGE_SIZE = 6
|
||||
@@ -318,10 +321,11 @@ def _get_group_status(group_key: str) -> tuple[str, str]:
|
||||
|
||||
if key == 'core':
|
||||
token_ok = bool(getattr(settings, 'BOT_TOKEN', ''))
|
||||
channel_ok = bool(settings.CHANNEL_LINK or not settings.CHANNEL_IS_REQUIRED_SUB)
|
||||
if token_ok and channel_ok:
|
||||
# Channel subscription channels are now managed via DB (admin panel),
|
||||
# not a single CHANNEL_LINK setting. Dashboard cannot async-query DB here.
|
||||
if token_ok:
|
||||
return '🟢', 'Бот готов к работе'
|
||||
return '🟡', 'Проверьте токен и обязательную подписку'
|
||||
return '🟡', 'Проверьте токен бота'
|
||||
|
||||
if key == 'subscriptions':
|
||||
price_ready = settings.PRICE_30_DAYS > 0 and settings.AVAILABLE_SUBSCRIPTION_PERIODS
|
||||
@@ -807,7 +811,7 @@ async def handle_import_message(
|
||||
content = ''
|
||||
if message.document:
|
||||
buffer = io.BytesIO()
|
||||
await message.document.download(destination=buffer)
|
||||
await message.bot.download(message.document, destination=buffer)
|
||||
buffer.seek(0)
|
||||
content = buffer.read().decode('utf-8', errors='ignore')
|
||||
else:
|
||||
@@ -2689,6 +2693,128 @@ async def apply_setting_choice(
|
||||
await callback.answer('Значение обновлено')
|
||||
|
||||
|
||||
# ── Remnawave App Config Selector ──
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_remna_config_menu(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs):
|
||||
"""Show available Remnawave subscription page configs for selection."""
|
||||
current_uuid = bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
|
||||
|
||||
try:
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
configs = await api.get_subscription_page_configs()
|
||||
except Exception as e:
|
||||
logger.error('Failed to load Remnawave configs', error=e)
|
||||
await callback.answer('Ошибка загрузки конфигов', show_alert=True)
|
||||
return
|
||||
|
||||
keyboard: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
if not configs:
|
||||
text = (
|
||||
'📱 <b>Конфиг приложений (Remnawave)</b>\n\n'
|
||||
'В Remnawave не найдено конфигураций страниц подписки.\n\n'
|
||||
'Создайте конфигурацию в панели Remnawave, затем вернитесь сюда для выбора.'
|
||||
)
|
||||
else:
|
||||
text = '📱 <b>Конфиг приложений (Remnawave)</b>\n\n'
|
||||
if current_uuid:
|
||||
current_name = next((c.name for c in configs if c.uuid == current_uuid), None)
|
||||
if current_name:
|
||||
text += f'✅ Текущий: <b>{html.escape(current_name)}</b>\n\n'
|
||||
else:
|
||||
text += f'⚠️ Текущий UUID не найден: <code>{html.escape(str(current_uuid))}</code>\n\n'
|
||||
else:
|
||||
text += 'ℹ️ Конфиг не выбран (гайд-режим отключён)\n\n'
|
||||
|
||||
text += 'Выберите конфигурацию для гайд-режима:'
|
||||
|
||||
for config in configs:
|
||||
prefix = '✅ ' if config.uuid == current_uuid else ''
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=f'{prefix}{config.name}',
|
||||
callback_data=f'admin_remna_select_{config.uuid}',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if current_uuid:
|
||||
keyboard.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🗑 Сбросить (отключить гайд-режим)',
|
||||
callback_data='admin_remna_clear',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_submenu_settings')])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def select_remna_config(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs):
|
||||
"""Select a Remnawave subscription page config."""
|
||||
uuid = callback.data.replace('admin_remna_select_', '')
|
||||
|
||||
# Validate UUID format
|
||||
import re as _re
|
||||
|
||||
if not _re.match(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}$', uuid):
|
||||
await callback.answer('Некорректный UUID конфигурации', show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', uuid)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error('Failed to save Remnawave config UUID', error=e)
|
||||
await callback.answer('Ошибка сохранения', show_alert=True)
|
||||
return
|
||||
|
||||
# Invalidate app config cache
|
||||
from app.handlers.subscription.common import invalidate_app_config_cache
|
||||
|
||||
invalidate_app_config_cache()
|
||||
|
||||
await callback.answer('✅ Конфиг выбран', show_alert=True)
|
||||
|
||||
# Re-render the menu
|
||||
await show_remna_config_menu(callback, db_user=db_user, db=db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def clear_remna_config(callback: types.CallbackQuery, db_user: User, db: AsyncSession, **kwargs):
|
||||
"""Clear the Remnawave config, disabling guide mode until new config is selected."""
|
||||
try:
|
||||
await bot_configuration_service.set_value(db, 'CABINET_REMNA_SUB_CONFIG', '')
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error('Failed to clear Remnawave config', error=e)
|
||||
await callback.answer('Ошибка сброса', show_alert=True)
|
||||
return
|
||||
|
||||
from app.handlers.subscription.common import invalidate_app_config_cache
|
||||
|
||||
invalidate_app_config_cache()
|
||||
|
||||
await callback.answer('✅ Конфиг сброшен', show_alert=True)
|
||||
await show_remna_config_menu(callback, db_user=db_user, db=db)
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher) -> None:
|
||||
dp.callback_query.register(
|
||||
show_bot_config_menu,
|
||||
@@ -2788,3 +2914,16 @@ def register_handlers(dp: Dispatcher) -> None:
|
||||
handle_import_message,
|
||||
BotConfigStates.waiting_for_import_file,
|
||||
)
|
||||
# Remnawave app config selector
|
||||
dp.callback_query.register(
|
||||
show_remna_config_menu,
|
||||
F.data == 'admin_remna_config',
|
||||
)
|
||||
dp.callback_query.register(
|
||||
select_remna_config,
|
||||
F.data.startswith('admin_remna_select_'),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
clear_remna_config,
|
||||
F.data == 'admin_remna_clear',
|
||||
)
|
||||
|
||||
@@ -1402,17 +1402,6 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
|
||||
# Задержка между батчами для соблюдения rate limits
|
||||
await asyncio.sleep(_BATCH_DELAY)
|
||||
|
||||
# Фоновая очистка заблокировавших бота пользователей
|
||||
if blocked_telegram_ids:
|
||||
from app.services.broadcast_service import _background_tasks, cleanup_blocked_broadcast_users
|
||||
|
||||
task = asyncio.create_task(
|
||||
cleanup_blocked_broadcast_users(blocked_telegram_ids),
|
||||
name=f'broadcast-{broadcast_id}-blocked-cleanup',
|
||||
)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
# Учитываем пропущенных email-only пользователей
|
||||
skipped_email_users = total_users_count - total_recipients
|
||||
if skipped_email_users > 0:
|
||||
|
||||
@@ -137,13 +137,16 @@ def _build_notification_settings_view(language: str):
|
||||
return summary_text, keyboard
|
||||
|
||||
|
||||
def _build_notification_preview_message(language: str, notification_type: str):
|
||||
async def _build_notification_preview_message(language: str, notification_type: str):
|
||||
texts = get_texts(language)
|
||||
now = datetime.now(UTC)
|
||||
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
|
||||
header = '🧪 <b>Тестовое уведомление мониторинга</b>\n\n'
|
||||
|
||||
if notification_type == 'trial_channel_unsubscribed':
|
||||
@@ -157,25 +160,9 @@ def _build_notification_preview_message(language: str, notification_type: str):
|
||||
)
|
||||
check_button = texts.t('CHANNEL_CHECK_BUTTON', '✅ Я подписался')
|
||||
message = template.format(check_button=check_button)
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
if settings.CHANNEL_LINK:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
|
||||
url=settings.CHANNEL_LINK,
|
||||
)
|
||||
]
|
||||
)
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=check_button,
|
||||
callback_data='sub_channel_check',
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
# Use all required channels for the preview keyboard
|
||||
required_channels = await channel_subscription_service.get_required_channels()
|
||||
keyboard = get_channel_sub_keyboard(required_channels, language=language)
|
||||
elif notification_type == 'expired_1d':
|
||||
template = texts.get(
|
||||
'SUBSCRIPTION_EXPIRED_1D',
|
||||
@@ -307,7 +294,7 @@ def _build_notification_preview_message(language: str, notification_type: str):
|
||||
|
||||
|
||||
async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None:
|
||||
message, keyboard = _build_notification_preview_message(language, notification_type)
|
||||
message, keyboard = await _build_notification_preview_message(language, notification_type)
|
||||
await bot.send_message(
|
||||
chat_id,
|
||||
message,
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Admin handler for managing required channel subscriptions."""
|
||||
|
||||
import structlog
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
||||
|
||||
from app.database.crud.required_channel import (
|
||||
add_channel,
|
||||
delete_channel,
|
||||
get_all_channels,
|
||||
get_channel_by_id,
|
||||
toggle_channel,
|
||||
validate_channel_id,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
from app.utils.decorators import admin_required
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = Router(name='admin_required_channels')
|
||||
|
||||
|
||||
class AddChannelStates(StatesGroup):
|
||||
waiting_channel_id = State()
|
||||
waiting_channel_link = State()
|
||||
waiting_channel_title = State()
|
||||
|
||||
|
||||
# -- List channels ----------------------------------------------------------------
|
||||
|
||||
|
||||
def _channels_keyboard(channels: list) -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
for ch in channels:
|
||||
status = '✅' if ch.is_active else '❌'
|
||||
title = ch.title or ch.channel_id
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'{status} {title}',
|
||||
callback_data=f'reqch:view:{ch.id}',
|
||||
)
|
||||
]
|
||||
)
|
||||
buttons.append([InlineKeyboardButton(text='➕ Добавить канал', callback_data='reqch:add')])
|
||||
buttons.append([InlineKeyboardButton(text='◀️ Назад', callback_data='admin_submenu_settings')])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def _channel_detail_keyboard(channel_id: int, is_active: bool) -> InlineKeyboardMarkup:
|
||||
toggle_text = '❌ Отключить' if is_active else '✅ Включить'
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=toggle_text, callback_data=f'reqch:toggle:{channel_id}')],
|
||||
[InlineKeyboardButton(text='🗑 Удалить', callback_data=f'reqch:delete:{channel_id}')],
|
||||
[InlineKeyboardButton(text='◀️ К списку', callback_data='reqch:list')],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == 'reqch:list')
|
||||
@admin_required
|
||||
async def show_channels_list(callback: CallbackQuery, **kwargs) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
channels = await get_all_channels(db)
|
||||
|
||||
if not channels:
|
||||
text = '<b>📢 Обязательные каналы</b>\n\nКаналы не настроены. Нажмите «Добавить» чтобы создать.'
|
||||
else:
|
||||
lines = ['<b>📢 Обязательные каналы</b>\n']
|
||||
for ch in channels:
|
||||
status = '✅' if ch.is_active else '❌'
|
||||
title = ch.title or ch.channel_id
|
||||
lines.append(f'{status} <code>{ch.channel_id}</code> — {title}')
|
||||
text = '\n'.join(lines)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=_channels_keyboard(channels))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith('reqch:view:'))
|
||||
@admin_required
|
||||
async def view_channel(callback: CallbackQuery, **kwargs) -> None:
|
||||
try:
|
||||
channel_db_id = int(callback.data.split(':')[2])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Неверный ID канала', show_alert=True)
|
||||
return
|
||||
async with AsyncSessionLocal() as db:
|
||||
ch = await get_channel_by_id(db, channel_db_id)
|
||||
|
||||
if not ch:
|
||||
await callback.answer('Канал не найден', show_alert=True)
|
||||
return
|
||||
|
||||
status = '✅ Активен' if ch.is_active else '❌ Отключён'
|
||||
text = (
|
||||
f'<b>{ch.title or "Без названия"}</b>\n\n'
|
||||
f'<b>ID:</b> <code>{ch.channel_id}</code>\n'
|
||||
f'<b>Ссылка:</b> {ch.channel_link or "—"}\n'
|
||||
f'<b>Статус:</b> {status}\n'
|
||||
f'<b>Порядок:</b> {ch.sort_order}'
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=_channel_detail_keyboard(ch.id, ch.is_active))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# -- Toggle / Delete ---------------------------------------------------------------
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith('reqch:toggle:'))
|
||||
@admin_required
|
||||
async def toggle_channel_handler(callback: CallbackQuery, **kwargs) -> None:
|
||||
try:
|
||||
channel_db_id = int(callback.data.split(':')[2])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Неверный ID канала', show_alert=True)
|
||||
return
|
||||
async with AsyncSessionLocal() as db:
|
||||
ch = await toggle_channel(db, channel_db_id)
|
||||
|
||||
if ch:
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
status = 'включён' if ch.is_active else 'отключён'
|
||||
await callback.answer(f'Канал {status}', show_alert=True)
|
||||
|
||||
# Refresh list
|
||||
async with AsyncSessionLocal() as db:
|
||||
channels = await get_all_channels(db)
|
||||
await callback.message.edit_text(
|
||||
'<b>📢 Обязательные каналы</b>',
|
||||
reply_markup=_channels_keyboard(channels),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith('reqch:delete:'))
|
||||
@admin_required
|
||||
async def delete_channel_handler(callback: CallbackQuery, **kwargs) -> None:
|
||||
try:
|
||||
channel_db_id = int(callback.data.split(':')[2])
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer('Неверный ID канала', show_alert=True)
|
||||
return
|
||||
async with AsyncSessionLocal() as db:
|
||||
ok = await delete_channel(db, channel_db_id)
|
||||
|
||||
if ok:
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
await callback.answer('Канал удалён', show_alert=True)
|
||||
else:
|
||||
await callback.answer('Ошибка удаления', show_alert=True)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
channels = await get_all_channels(db)
|
||||
await callback.message.edit_text(
|
||||
'<b>📢 Обязательные каналы</b>',
|
||||
reply_markup=_channels_keyboard(channels),
|
||||
)
|
||||
|
||||
|
||||
# -- Add channel flow --------------------------------------------------------------
|
||||
|
||||
|
||||
@router.callback_query(F.data == 'reqch:add')
|
||||
@admin_required
|
||||
async def start_add_channel(callback: CallbackQuery, state: FSMContext, **kwargs) -> None:
|
||||
await state.set_state(AddChannelStates.waiting_channel_id)
|
||||
await callback.message.edit_text(
|
||||
'<b>➕ Добавить канал</b>\n\n'
|
||||
'Отправьте числовой ID канала (например <code>1234567890</code>).\n'
|
||||
'Префикс <code>-100</code> добавляется автоматически.'
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.message(AddChannelStates.waiting_channel_id)
|
||||
@admin_required
|
||||
async def process_channel_id(message: Message, state: FSMContext, **kwargs) -> None:
|
||||
if not message.text:
|
||||
await message.answer('Отправьте текстовое сообщение.')
|
||||
return
|
||||
channel_id = message.text.strip()
|
||||
|
||||
# Validate and normalize channel_id (auto-prefixes -100 for bare digits)
|
||||
try:
|
||||
channel_id = validate_channel_id(channel_id)
|
||||
except ValueError as e:
|
||||
await message.answer(f'Неверный формат. {e}\n\nПопробуйте ещё раз:')
|
||||
return
|
||||
|
||||
await state.update_data(channel_id=channel_id)
|
||||
await state.set_state(AddChannelStates.waiting_channel_link)
|
||||
await message.answer(
|
||||
f'Канал: <code>{channel_id}</code>\n\n'
|
||||
'Теперь отправьте ссылку на канал (например <code>https://t.me/mychannel</code>)\n'
|
||||
'Или отправьте <code>-</code> чтобы пропустить:'
|
||||
)
|
||||
|
||||
|
||||
@router.message(AddChannelStates.waiting_channel_link)
|
||||
@admin_required
|
||||
async def process_channel_link(message: Message, state: FSMContext, **kwargs) -> None:
|
||||
if not message.text:
|
||||
await message.answer('Отправьте текстовое сообщение.')
|
||||
return
|
||||
link = message.text.strip()
|
||||
if link == '-':
|
||||
link = None
|
||||
|
||||
if link is not None:
|
||||
# Validate and normalize channel link
|
||||
if not link.startswith(('https://t.me/', 'http://t.me/', '@')):
|
||||
await message.answer('Ссылка должна быть URL вида t.me или @username. Попробуйте ещё раз:')
|
||||
return
|
||||
if link.startswith('@'):
|
||||
link = f'https://t.me/{link[1:]}'
|
||||
if link.startswith('http://'):
|
||||
link = link.replace('http://', 'https://', 1)
|
||||
|
||||
await state.update_data(channel_link=link)
|
||||
await state.set_state(AddChannelStates.waiting_channel_title)
|
||||
await message.answer(
|
||||
'Отправьте название канала (например <code>Новости проекта</code>)\n'
|
||||
'Или отправьте <code>-</code> чтобы пропустить:'
|
||||
)
|
||||
|
||||
|
||||
@router.message(AddChannelStates.waiting_channel_title)
|
||||
@admin_required
|
||||
async def process_channel_title(message: Message, state: FSMContext, **kwargs) -> None:
|
||||
if not message.text:
|
||||
await message.answer('Отправьте текстовое сообщение.')
|
||||
return
|
||||
title = message.text.strip()
|
||||
if title == '-':
|
||||
title = None
|
||||
|
||||
data = await state.get_data()
|
||||
await state.clear()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
ch = await add_channel(
|
||||
db,
|
||||
channel_id=data['channel_id'],
|
||||
channel_link=data.get('channel_link'),
|
||||
title=title,
|
||||
)
|
||||
await channel_subscription_service.invalidate_channels_cache()
|
||||
|
||||
text = (
|
||||
'✅ Канал добавлен!\n\n'
|
||||
f'<b>ID:</b> <code>{ch.channel_id}</code>\n'
|
||||
f'<b>Ссылка:</b> {ch.channel_link or "—"}\n'
|
||||
f'<b>Название:</b> {ch.title or "—"}'
|
||||
)
|
||||
except Exception as e:
|
||||
text = '❌ Ошибка добавления канала. Попробуйте ещё раз.'
|
||||
logger.error('Error adding channel', error=e)
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
channels = await get_all_channels(db)
|
||||
|
||||
await message.answer(text, reply_markup=_channels_keyboard(channels))
|
||||
|
||||
|
||||
def register_handlers(dp_router: Router) -> None:
|
||||
dp_router.include_router(router)
|
||||
@@ -4013,7 +4013,11 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm
|
||||
|
||||
async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id: int) -> bool:
|
||||
try:
|
||||
from app.database.crud.subscription import deactivate_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.subscription import (
|
||||
deactivate_subscription,
|
||||
get_subscription_by_user_id,
|
||||
is_active_paid_subscription,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user_id)
|
||||
@@ -4021,6 +4025,13 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id
|
||||
logger.error('Подписка не найдена для пользователя', user_id=user_id)
|
||||
return False
|
||||
|
||||
if is_active_paid_subscription(subscription):
|
||||
logger.info(
|
||||
'⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
|
||||
user_id=user_id,
|
||||
)
|
||||
return False
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Handler for Freekassa balance top-up."""
|
||||
|
||||
import html
|
||||
|
||||
import structlog
|
||||
from aiogram import types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -18,12 +20,29 @@ from app.utils.decorators import error_handler
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
FREEKASSA_SUB_METHODS = {
|
||||
'freekassa_sbp': {'payment_system_id': 44, 'get_name': lambda: settings.get_freekassa_sbp_display_name()},
|
||||
'freekassa_card': {'payment_system_id': 36, 'get_name': lambda: settings.get_freekassa_card_display_name()},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_freekassa_params(
|
||||
payment_method: str | None,
|
||||
) -> tuple[int | None, str]:
|
||||
"""Return (payment_system_id, display_name) for a freekassa sub-method key."""
|
||||
if payment_method and payment_method in FREEKASSA_SUB_METHODS:
|
||||
meta = FREEKASSA_SUB_METHODS[payment_method]
|
||||
return meta['payment_system_id'], meta['get_name']()
|
||||
return None, settings.get_freekassa_display_name()
|
||||
|
||||
|
||||
async def _create_freekassa_payment_and_respond(
|
||||
message_or_callback,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
edit_message: bool = False,
|
||||
payment_method: str | None = None,
|
||||
):
|
||||
"""
|
||||
Common logic for creating Freekassa payment and sending response.
|
||||
@@ -34,10 +53,13 @@ async def _create_freekassa_payment_and_respond(
|
||||
db: Database session
|
||||
amount_kopeks: Amount in kopeks
|
||||
edit_message: Whether to edit existing message or send new one
|
||||
payment_method: Sub-method key (freekassa_sbp, freekassa_card, or None for default)
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
amount_rub = amount_kopeks / 100
|
||||
|
||||
ps_id, display_name = _resolve_freekassa_params(payment_method)
|
||||
|
||||
# Create payment
|
||||
payment_service = PaymentService()
|
||||
|
||||
@@ -53,6 +75,8 @@ async def _create_freekassa_payment_and_respond(
|
||||
description=description,
|
||||
email=getattr(db_user, 'email', None),
|
||||
language=db_user.language,
|
||||
payment_system_id=ps_id,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
if not result:
|
||||
@@ -74,7 +98,6 @@ async def _create_freekassa_payment_and_respond(
|
||||
return
|
||||
|
||||
payment_url = result.get('payment_url')
|
||||
display_name = settings.get_freekassa_display_name()
|
||||
|
||||
# Create keyboard with payment button
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
@@ -103,7 +126,7 @@ async def _create_freekassa_payment_and_respond(
|
||||
'Сумма: <b>{amount}₽</b>\n\n'
|
||||
'Нажмите кнопку ниже для оплаты.\n'
|
||||
'После успешной оплаты баланс будет пополнен автоматически.',
|
||||
).format(name=display_name, amount=f'{amount_rub:.2f}')
|
||||
).format(name=html.escape(display_name), amount=f'{amount_rub:.2f}')
|
||||
|
||||
if edit_message:
|
||||
await message_or_callback.edit_text(
|
||||
@@ -128,9 +151,11 @@ async def process_freekassa_payment_amount(
|
||||
db: AsyncSession,
|
||||
amount_kopeks: int,
|
||||
state: FSMContext,
|
||||
payment_method: str | None = None,
|
||||
):
|
||||
"""
|
||||
Process payment amount directly (called from quick_amount handlers).
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -183,21 +208,44 @@ async def process_freekassa_payment_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=False,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_freekassa_topup(
|
||||
async def _start_freekassa_topup_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Start Freekassa top-up process - ask for amount.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Проверка доступности метода
|
||||
if not settings.is_freekassa_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Проверка ограничения на пополнение
|
||||
if getattr(db_user, 'restriction_topup', False):
|
||||
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
|
||||
@@ -215,11 +263,11 @@ async def start_freekassa_topup(
|
||||
return
|
||||
|
||||
await state.set_state(BalanceStates.waiting_for_amount)
|
||||
await state.update_data(payment_method='freekassa')
|
||||
await state.update_data(payment_method=payment_method)
|
||||
|
||||
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS // 100
|
||||
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS // 100
|
||||
display_name = settings.get_freekassa_display_name()
|
||||
_, display_name = _resolve_freekassa_params(payment_method)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -249,6 +297,39 @@ async def start_freekassa_topup(
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_freekassa_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_freekassa_sbp_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def start_freekassa_card_topup(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _start_freekassa_topup_impl(callback, db_user, state, 'freekassa_card')
|
||||
|
||||
|
||||
FREEKASSA_PAYMENT_METHODS = {'freekassa', 'freekassa_sbp', 'freekassa_card'}
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_custom_amount(
|
||||
message: types.Message,
|
||||
@@ -260,7 +341,7 @@ async def process_freekassa_custom_amount(
|
||||
Process custom amount input for Freekassa payment.
|
||||
"""
|
||||
data = await state.get_data()
|
||||
if data.get('payment_method') != 'freekassa':
|
||||
if data.get('payment_method') not in FREEKASSA_PAYMENT_METHODS:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
@@ -285,19 +366,21 @@ async def process_freekassa_custom_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
state=state,
|
||||
payment_method=data.get('payment_method'),
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_quick_amount(
|
||||
async def _process_freekassa_quick_amount_impl(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
payment_method: str,
|
||||
):
|
||||
"""
|
||||
Process quick amount selection for Freekassa payment.
|
||||
Called when user clicks a predefined amount button.
|
||||
payment_method: 'freekassa', 'freekassa_sbp', 'freekassa_card'
|
||||
"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -308,7 +391,21 @@ async def process_freekassa_quick_amount(
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|freekassa|{amount_kopeks}
|
||||
if payment_method == 'freekassa_sbp' and not settings.is_freekassa_sbp_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
if payment_method == 'freekassa_card' and not settings.is_freekassa_card_enabled():
|
||||
await callback.answer(
|
||||
texts.t('FREEKASSA_NOT_AVAILABLE', 'Freekassa временно недоступен'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract amount from callback data: topup_amount|{method}|{amount_kopeks}
|
||||
try:
|
||||
parts = callback.data.split('|')
|
||||
if len(parts) >= 3:
|
||||
@@ -363,4 +460,35 @@ async def process_freekassa_quick_amount(
|
||||
db=db,
|
||||
amount_kopeks=amount_kopeks,
|
||||
edit_message=True,
|
||||
payment_method=payment_method,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_sbp_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_sbp')
|
||||
|
||||
|
||||
@error_handler
|
||||
async def process_freekassa_card_quick_amount(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext,
|
||||
):
|
||||
await _process_freekassa_quick_amount_impl(callback, db_user, db, state, 'freekassa_card')
|
||||
|
||||
@@ -113,11 +113,13 @@ async def route_payment_by_method(
|
||||
await process_cloudpayments_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
return True
|
||||
|
||||
if payment_method == 'freekassa':
|
||||
if payment_method in ('freekassa', 'freekassa_sbp', 'freekassa_card'):
|
||||
from .freekassa import process_freekassa_payment_amount
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
await process_freekassa_payment_amount(message, db_user, db, amount_kopeks, state)
|
||||
await process_freekassa_payment_amount(
|
||||
message, db_user, db, amount_kopeks, state, payment_method=payment_method
|
||||
)
|
||||
return True
|
||||
|
||||
if payment_method == 'kassa_ai':
|
||||
@@ -838,10 +840,21 @@ def register_balance_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_cloudpayments_payment, F.data == 'topup_cloudpayments')
|
||||
dp.callback_query.register(handle_cloudpayments_quick_amount, F.data.startswith('topup_amount|cloudpayments|'))
|
||||
|
||||
from .freekassa import process_freekassa_quick_amount, start_freekassa_topup
|
||||
from .freekassa import (
|
||||
process_freekassa_card_quick_amount,
|
||||
process_freekassa_quick_amount,
|
||||
process_freekassa_sbp_quick_amount,
|
||||
start_freekassa_card_topup,
|
||||
start_freekassa_sbp_topup,
|
||||
start_freekassa_topup,
|
||||
)
|
||||
|
||||
dp.callback_query.register(start_freekassa_topup, F.data == 'topup_freekassa')
|
||||
dp.callback_query.register(process_freekassa_quick_amount, F.data.startswith('topup_amount|freekassa|'))
|
||||
dp.callback_query.register(start_freekassa_sbp_topup, F.data == 'topup_freekassa_sbp')
|
||||
dp.callback_query.register(process_freekassa_sbp_quick_amount, F.data.startswith('topup_amount|freekassa_sbp|'))
|
||||
dp.callback_query.register(start_freekassa_card_topup, F.data == 'topup_freekassa_card')
|
||||
dp.callback_query.register(process_freekassa_card_quick_amount, F.data.startswith('topup_amount|freekassa_card|'))
|
||||
|
||||
from .kassa_ai import process_kassa_ai_quick_amount, start_kassa_ai_topup
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""ChatMemberUpdated event handler for real-time channel subscription tracking.
|
||||
|
||||
KEY COMPONENT for scalability: the bot receives push notifications from Telegram
|
||||
when users join/leave channels, instead of polling via getChatMember.
|
||||
Requirement: bot must be admin in each required channel.
|
||||
|
||||
IMPORTANT: Events are FILTERED to only process required channels.
|
||||
Without filtering, the bot would process events from ALL channels it admins.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot, Router
|
||||
from aiogram.filters import IS_MEMBER, IS_NOT_MEMBER, ChatMemberUpdatedFilter
|
||||
from aiogram.types import ChatMemberUpdated
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
|
||||
from app.database.crud.user import get_user_by_telegram_id
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.database.models import SubscriptionStatus, UserStatus
|
||||
from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = Router(name='channel_member')
|
||||
|
||||
|
||||
async def _is_required_channel(channel_id: str) -> bool:
|
||||
"""Check if the channel_id is one of our required channels."""
|
||||
required_ids = await channel_subscription_service.get_required_channel_ids()
|
||||
return channel_id in required_ids
|
||||
|
||||
|
||||
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=IS_NOT_MEMBER >> IS_MEMBER))
|
||||
async def on_user_joined_channel(event: ChatMemberUpdated, bot: Bot) -> None:
|
||||
"""User subscribed to a channel -- update cache and reactivate VPN if applicable."""
|
||||
user = event.new_chat_member.user
|
||||
channel_id = str(event.chat.id) # Normalize int to str (DB stores string)
|
||||
|
||||
# FILTER: Only process events for required channels
|
||||
if not await _is_required_channel(channel_id):
|
||||
return
|
||||
|
||||
await channel_subscription_service.on_user_joined(user.id, channel_id)
|
||||
|
||||
# Check if user is now subscribed to ALL required channels
|
||||
if not settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
return
|
||||
|
||||
is_all_subscribed = await channel_subscription_service.is_user_subscribed_to_all(user.id)
|
||||
if not is_all_subscribed:
|
||||
return # Still missing some channels
|
||||
|
||||
# Reactivate subscription if it was disabled due to channel unsubscribe
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
db_user = await get_user_by_telegram_id(db, user.id)
|
||||
if not db_user or not db_user.subscription:
|
||||
return
|
||||
if db_user.status == UserStatus.BLOCKED.value:
|
||||
return
|
||||
|
||||
subscription = db_user.subscription
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
return
|
||||
|
||||
# Don't reactivate expired subscriptions
|
||||
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
|
||||
return
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
logger.info('Subscription reactivated via channel event', telegram_id=user.id)
|
||||
|
||||
# Re-enable in RemnaWave panel
|
||||
if db_user.remnawave_uuid:
|
||||
service = SubscriptionService()
|
||||
try:
|
||||
await service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error('Failed to enable RemnaWave user', error=api_error)
|
||||
|
||||
# Notify the user
|
||||
try:
|
||||
texts = get_texts(db_user.language or DEFAULT_LANGUAGE)
|
||||
notification_text = texts.t(
|
||||
'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE',
|
||||
'Your subscription has been restored! Thank you for subscribing to the channels.',
|
||||
)
|
||||
await bot.send_message(user.id, notification_text)
|
||||
except Exception as notify_error:
|
||||
logger.warning('Failed to send notification', telegram_id=user.id, error=notify_error)
|
||||
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error('Error reactivating subscription on channel join', error=e)
|
||||
await db.rollback()
|
||||
|
||||
|
||||
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=IS_MEMBER >> IS_NOT_MEMBER))
|
||||
async def on_user_left_channel(event: ChatMemberUpdated, bot: Bot) -> None:
|
||||
"""User unsubscribed from a channel -- update cache and deactivate VPN if applicable."""
|
||||
user = event.old_chat_member.user
|
||||
channel_id = str(event.chat.id) # Normalize int to str (DB stores string)
|
||||
|
||||
# FILTER: Only process events for required channels
|
||||
if not await _is_required_channel(channel_id):
|
||||
return
|
||||
|
||||
await channel_subscription_service.on_user_left(user.id, channel_id)
|
||||
|
||||
if not settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
return
|
||||
|
||||
# Skip admins -- never deactivate admin subscriptions
|
||||
if settings.is_admin(user.id):
|
||||
return
|
||||
|
||||
# Fetch per-channel settings to decide whether to disable
|
||||
channel_settings = await channel_subscription_service.get_channel_settings(channel_id)
|
||||
if not channel_settings:
|
||||
return
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
db_user = await get_user_by_telegram_id(db, user.id)
|
||||
if not db_user or not db_user.subscription:
|
||||
return
|
||||
|
||||
subscription = db_user.subscription
|
||||
if subscription.status != SubscriptionStatus.ACTIVE.value:
|
||||
return
|
||||
|
||||
# Per-channel settings: check if this channel requires deactivation
|
||||
if not channel_subscription_service.should_disable_subscription(channel_settings, subscription.is_trial):
|
||||
return
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
logger.info('Subscription deactivated via channel event', telegram_id=user.id)
|
||||
|
||||
# Disable in RemnaWave panel
|
||||
if db_user.remnawave_uuid:
|
||||
service = SubscriptionService()
|
||||
try:
|
||||
await service.disable_remnawave_user(db_user.remnawave_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error('Failed to disable RemnaWave user', error=api_error)
|
||||
|
||||
# Notify the user with channel subscription keyboard
|
||||
try:
|
||||
texts = get_texts(db_user.language or DEFAULT_LANGUAGE)
|
||||
unsub_channels = await channel_subscription_service.get_channels_with_status(user.id)
|
||||
notification_text = texts.t(
|
||||
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
|
||||
'Your subscription has been paused because you left a required channel.',
|
||||
)
|
||||
channel_kb = get_channel_sub_keyboard(unsub_channels, language=db_user.language or DEFAULT_LANGUAGE)
|
||||
await bot.send_message(user.id, notification_text, reply_markup=channel_kb)
|
||||
except Exception as notify_error:
|
||||
logger.warning('Failed to send notification', telegram_id=user.id, error=notify_error)
|
||||
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error('Error deactivating subscription on channel leave', error=e)
|
||||
await db.rollback()
|
||||
|
||||
|
||||
def register_handlers(dp_router: Router) -> None:
|
||||
"""Register channel member event handlers on the dispatcher/router."""
|
||||
dp_router.include_router(router)
|
||||
+27
-19
@@ -2,7 +2,6 @@ from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot, Dispatcher, F, types
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -37,6 +36,7 @@ from app.middlewares.channel_checker import (
|
||||
)
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.campaign_service import AdvertisingCampaignService
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
from app.services.main_menu_button_service import MainMenuButtonService
|
||||
from app.services.pinned_message_service import (
|
||||
deliver_pinned_message_to_user,
|
||||
@@ -1174,11 +1174,14 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
if existing_user and existing_user.status == UserStatus.DELETED.value:
|
||||
logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=callback.from_user.id)
|
||||
|
||||
# Prevent self-referral when partner re-registers via own campaign link
|
||||
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
|
||||
|
||||
existing_user.username = callback.from_user.username
|
||||
existing_user.first_name = callback.from_user.first_name
|
||||
existing_user.last_name = callback.from_user.last_name
|
||||
existing_user.language = language
|
||||
existing_user.referred_by_id = referrer_id
|
||||
existing_user.referred_by_id = safe_referrer_id
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
@@ -1212,7 +1215,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
logger.info('🔄 Обновляем существующего пользователя', from_user_id=callback.from_user.id)
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
if referrer_id and referrer_id != existing_user.id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
existing_user.updated_at = datetime.now(UTC)
|
||||
@@ -1222,7 +1225,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
user = existing_user
|
||||
|
||||
if referrer_id:
|
||||
if referrer_id and referrer_id != user.id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id, callback.bot)
|
||||
logger.info('✅ Реферальная регистрация обработана для', user_id=user.id)
|
||||
@@ -1436,11 +1439,14 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
if existing_user and existing_user.status == UserStatus.DELETED.value:
|
||||
logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=message.from_user.id)
|
||||
|
||||
# Prevent self-referral when partner re-registers via own campaign link
|
||||
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
|
||||
|
||||
existing_user.username = message.from_user.username
|
||||
existing_user.first_name = message.from_user.first_name
|
||||
existing_user.last_name = message.from_user.last_name
|
||||
existing_user.language = language
|
||||
existing_user.referred_by_id = referrer_id
|
||||
existing_user.referred_by_id = safe_referrer_id
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.balance_kopeks = 0
|
||||
existing_user.has_had_paid_subscription = False
|
||||
@@ -1474,7 +1480,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
logger.info('🔄 Обновляем существующего пользователя', from_user_id=message.from_user.id)
|
||||
existing_user.status = UserStatus.ACTIVE.value
|
||||
existing_user.language = language
|
||||
if referrer_id and not existing_user.referred_by_id:
|
||||
if referrer_id and referrer_id != existing_user.id and not existing_user.referred_by_id:
|
||||
existing_user.referred_by_id = referrer_id
|
||||
|
||||
existing_user.updated_at = datetime.now(UTC)
|
||||
@@ -1484,7 +1490,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
await db.refresh(existing_user, ['subscription'])
|
||||
user = existing_user
|
||||
|
||||
if referrer_id:
|
||||
if referrer_id and referrer_id != user.id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id, message.bot)
|
||||
logger.info('✅ Реферальная регистрация обработана для', user_id=user.id)
|
||||
@@ -1860,20 +1866,22 @@ async def required_sub_channel_check(
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=query.from_user.id)
|
||||
# Ensure bot is set on service
|
||||
if not channel_subscription_service.bot:
|
||||
channel_subscription_service.bot = bot
|
||||
|
||||
if chat_member.status not in [
|
||||
ChatMemberStatus.MEMBER,
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR,
|
||||
]:
|
||||
# Invalidate cache for fresh check (user just clicked "I subscribed")
|
||||
await channel_subscription_service.invalidate_user_cache(query.from_user.id)
|
||||
|
||||
is_subscribed = await channel_subscription_service.is_user_subscribed_to_all(query.from_user.id)
|
||||
if not is_subscribed:
|
||||
# НЕ удаляем payload - пользователь может попробовать снова после подписки
|
||||
logger.info(
|
||||
"📦 CHANNEL CHECK: Подписка не подтверждена, payload '' сохранён для следующей попытки",
|
||||
'CHANNEL CHECK: Подписка не подтверждена, payload сохранён для следующей попытки',
|
||||
pending_start_payload=pending_start_payload,
|
||||
)
|
||||
return await query.answer(
|
||||
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'),
|
||||
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', 'Please subscribe to all required channels first!'),
|
||||
show_alert=True,
|
||||
)
|
||||
|
||||
@@ -1989,7 +1997,7 @@ async def required_sub_channel_check(
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=get_logo_media(),
|
||||
@@ -2046,7 +2054,7 @@ async def required_sub_channel_check(
|
||||
logger.info('✅ CHANNEL CHECK: pending_start_payload удален из state после создания пользователя')
|
||||
|
||||
# Обрабатываем реферальную регистрацию
|
||||
if referrer_id:
|
||||
if referrer_id and referrer_id != user.id:
|
||||
try:
|
||||
await process_referral_registration(db, user.id, referrer_id, bot)
|
||||
logger.info('✅ CHANNEL CHECK: Реферальная регистрация обработана для', user_id=user.id)
|
||||
@@ -2082,7 +2090,7 @@ async def required_sub_channel_check(
|
||||
custom_buttons=custom_buttons,
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=get_logo_media(),
|
||||
@@ -2112,7 +2120,7 @@ async def required_sub_channel_check(
|
||||
else:
|
||||
rules_text = await get_rules(language)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
if settings.ENABLE_LOGO_MODE and len(rules_text) <= 900:
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=get_logo_media(),
|
||||
|
||||
@@ -11,16 +11,19 @@ from .autopay import (
|
||||
from .common import (
|
||||
build_redirect_link,
|
||||
create_deep_link,
|
||||
format_additional_section,
|
||||
format_traffic_display,
|
||||
get_apps_for_device,
|
||||
get_apps_for_platform_async,
|
||||
get_confirm_switch_traffic_keyboard,
|
||||
get_device_name,
|
||||
get_localized_value,
|
||||
get_platforms_list,
|
||||
get_reset_devices_confirm_keyboard,
|
||||
get_step_description,
|
||||
get_traffic_switch_keyboard,
|
||||
load_app_config,
|
||||
invalidate_app_config_cache,
|
||||
load_app_config_async,
|
||||
normalize_app,
|
||||
render_guide_blocks,
|
||||
resolve_button_url,
|
||||
update_traffic_prices,
|
||||
validate_traffic_price,
|
||||
)
|
||||
@@ -132,18 +135,17 @@ __all__ = [
|
||||
'devices_continue',
|
||||
'execute_change_devices',
|
||||
'execute_switch_traffic',
|
||||
'format_additional_section',
|
||||
'format_traffic_display',
|
||||
'get_apps_for_device',
|
||||
'get_apps_for_platform_async',
|
||||
'get_confirm_switch_traffic_keyboard',
|
||||
'get_countries_price_by_uuids_fallback',
|
||||
'get_current_devices_count',
|
||||
'get_current_devices_detailed',
|
||||
'get_device_name',
|
||||
'get_localized_value',
|
||||
'get_platforms_list',
|
||||
'get_reset_devices_confirm_keyboard',
|
||||
'get_servers_display_names',
|
||||
'get_step_description',
|
||||
'get_subscription_cost',
|
||||
'get_subscription_info_text',
|
||||
'get_traffic_packages_info',
|
||||
@@ -176,9 +178,13 @@ __all__ = [
|
||||
'handle_subscription_config_back',
|
||||
'handle_subscription_settings',
|
||||
'handle_switch_traffic',
|
||||
'load_app_config',
|
||||
'invalidate_app_config_cache',
|
||||
'load_app_config_async',
|
||||
'normalize_app',
|
||||
'refresh_traffic_config',
|
||||
'register_handlers',
|
||||
'render_guide_blocks',
|
||||
'resolve_button_url',
|
||||
'resume_subscription_checkout',
|
||||
'return_to_saved_cart',
|
||||
'save_cart_and_redirect_to_topup',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import html as html_mod
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
@@ -23,23 +26,34 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
# ── App config cache ──
|
||||
_app_config_cache: dict[str, Any] = {}
|
||||
_app_config_cache_ts: float = 0.0
|
||||
_app_config_lock = asyncio.Lock()
|
||||
|
||||
class _SafeFormatDict(dict):
|
||||
def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
|
||||
return '{' + key + '}'
|
||||
|
||||
_PLACEHOLDER_RE = re.compile(r'\{(\w+)\}')
|
||||
|
||||
|
||||
def _format_text_with_placeholders(template: str, values: dict[str, Any]) -> str:
|
||||
"""Safe placeholder substitution — only replaces simple {key} patterns.
|
||||
|
||||
Unlike str.format_map, this does NOT allow attribute access ({key.attr})
|
||||
or indexing ({key[0]}), preventing format string injection attacks.
|
||||
"""
|
||||
if not isinstance(template, str):
|
||||
return template
|
||||
|
||||
safe_values = _SafeFormatDict()
|
||||
safe_values.update(values)
|
||||
def _replace(match: re.Match) -> str:
|
||||
key = match.group(1)
|
||||
if key in values:
|
||||
return str(values[key])
|
||||
return match.group(0)
|
||||
|
||||
try:
|
||||
return template.format_map(safe_values)
|
||||
return _PLACEHOLDER_RE.sub(_replace, template)
|
||||
except Exception: # pragma: no cover - defensive logging
|
||||
logger.warning("Failed to format template '' with values", template=template, values=values)
|
||||
logger.warning('Failed to format template with values', template=template, values=values)
|
||||
return template
|
||||
|
||||
|
||||
@@ -152,23 +166,6 @@ def validate_traffic_price(gb: int) -> bool:
|
||||
return price > 0
|
||||
|
||||
|
||||
def load_app_config() -> dict[str, Any]:
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
config_path = settings.get_app_config_path()
|
||||
|
||||
with open(config_path, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
logger.error('Некорректный формат app-config.json: ожидается объект')
|
||||
except Exception as e:
|
||||
logger.error('Ошибка загрузки конфига приложений', error=e)
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def get_localized_value(values: Any, language: str, default_language: str = 'en') -> str:
|
||||
if not isinstance(values, dict):
|
||||
return ''
|
||||
@@ -199,39 +196,29 @@ def get_localized_value(values: Any, language: str, default_language: str = 'en'
|
||||
return ''
|
||||
|
||||
|
||||
def get_step_description(app: dict[str, Any], step_key: str, language: str) -> str:
|
||||
if not isinstance(app, dict):
|
||||
return ''
|
||||
|
||||
step = app.get(step_key)
|
||||
if not isinstance(step, dict):
|
||||
return ''
|
||||
|
||||
description = step.get('description')
|
||||
return get_localized_value(description, language)
|
||||
|
||||
|
||||
def format_additional_section(additional: Any, texts, language: str) -> str:
|
||||
if not isinstance(additional, dict):
|
||||
return ''
|
||||
|
||||
title = get_localized_value(additional.get('title'), language)
|
||||
description = get_localized_value(additional.get('description'), language)
|
||||
|
||||
def render_guide_blocks(blocks: list[dict], language: str) -> str:
|
||||
"""Render block-format guide steps to HTML text."""
|
||||
parts: list[str] = []
|
||||
|
||||
if title:
|
||||
parts.append(
|
||||
texts.t(
|
||||
'SUBSCRIPTION_ADDITIONAL_STEP_TITLE',
|
||||
'<b>{title}:</b>',
|
||||
).format(title=title)
|
||||
step_num = 1
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
title = block.get('title', {})
|
||||
desc = block.get('description', {})
|
||||
title_text = html_mod.escape(
|
||||
get_localized_value(title, language) if isinstance(title, dict) else str(title or '')
|
||||
)
|
||||
|
||||
if description:
|
||||
parts.append(description)
|
||||
|
||||
return '\n'.join(parts)
|
||||
desc_text = html_mod.escape(get_localized_value(desc, language) if isinstance(desc, dict) else str(desc or ''))
|
||||
if title_text or desc_text:
|
||||
step = f'<b>Шаг {step_num}'
|
||||
if title_text:
|
||||
step += f' - {title_text}'
|
||||
step += ':</b>'
|
||||
if desc_text:
|
||||
step += f'\n{desc_text}'
|
||||
parts.append(step)
|
||||
step_num += 1
|
||||
return '\n\n'.join(parts)
|
||||
|
||||
|
||||
def build_redirect_link(target_link: str | None, template: str | None) -> str | None:
|
||||
@@ -266,34 +253,13 @@ def build_redirect_link(target_link: str | None, template: str | None) -> str |
|
||||
return result
|
||||
|
||||
|
||||
def get_apps_for_device(device_type: str, language: str = 'ru') -> list[dict[str, Any]]:
|
||||
config = load_app_config()
|
||||
platforms = config.get('platforms', {}) if isinstance(config, dict) else {}
|
||||
|
||||
if not isinstance(platforms, dict):
|
||||
return []
|
||||
|
||||
device_mapping = {
|
||||
'ios': 'ios',
|
||||
'android': 'android',
|
||||
'windows': 'windows',
|
||||
'mac': 'macos',
|
||||
'tv': 'androidTV',
|
||||
'appletv': 'appleTV',
|
||||
'apple_tv': 'appleTV',
|
||||
}
|
||||
|
||||
config_key = device_mapping.get(device_type, device_type)
|
||||
apps = platforms.get(config_key, [])
|
||||
return apps if isinstance(apps, list) else []
|
||||
|
||||
|
||||
def get_device_name(device_type: str, language: str = 'ru') -> str:
|
||||
names = {
|
||||
'ios': 'iPhone/iPad',
|
||||
'android': 'Android',
|
||||
'windows': 'Windows',
|
||||
'mac': 'macOS',
|
||||
'linux': 'Linux',
|
||||
'tv': 'Android TV',
|
||||
'appletv': 'Apple TV',
|
||||
'apple_tv': 'Apple TV',
|
||||
@@ -302,6 +268,205 @@ def get_device_name(device_type: str, language: str = 'ru') -> str:
|
||||
return names.get(device_type, device_type)
|
||||
|
||||
|
||||
# ── Remnawave async config loader ──
|
||||
|
||||
_PLATFORM_DISPLAY = {
|
||||
'ios': {'name': 'iPhone/iPad', 'emoji': '📱'},
|
||||
'android': {'name': 'Android', 'emoji': '🤖'},
|
||||
'windows': {'name': 'Windows', 'emoji': '💻'},
|
||||
'macos': {'name': 'macOS', 'emoji': '🎯'},
|
||||
'linux': {'name': 'Linux', 'emoji': '🐧'},
|
||||
'androidTV': {'name': 'Android TV', 'emoji': '📺'},
|
||||
'appleTV': {'name': 'Apple TV', 'emoji': '📺'},
|
||||
}
|
||||
|
||||
# Map callback device_type keys to Remnawave platform keys
|
||||
_DEVICE_TO_PLATFORM = {
|
||||
'ios': 'ios',
|
||||
'android': 'android',
|
||||
'windows': 'windows',
|
||||
'mac': 'macos',
|
||||
'linux': 'linux',
|
||||
'tv': 'androidTV',
|
||||
'appletv': 'appleTV',
|
||||
'apple_tv': 'appleTV',
|
||||
}
|
||||
|
||||
# Reverse: Remnawave platform key → callback device_type
|
||||
_PLATFORM_TO_DEVICE = {
|
||||
'ios': 'ios',
|
||||
'android': 'android',
|
||||
'windows': 'windows',
|
||||
'macos': 'mac',
|
||||
'linux': 'linux',
|
||||
'androidTV': 'tv',
|
||||
'appleTV': 'appletv',
|
||||
}
|
||||
|
||||
|
||||
def _get_remnawave_config_uuid() -> str | None:
|
||||
try:
|
||||
from app.services.system_settings_service import bot_configuration_service
|
||||
|
||||
return bot_configuration_service.get_current_value('CABINET_REMNA_SUB_CONFIG')
|
||||
except Exception as e:
|
||||
logger.debug('Could not read CABINET_REMNA_SUB_CONFIG from service, using settings fallback', error=e)
|
||||
return getattr(settings, 'CABINET_REMNA_SUB_CONFIG', None)
|
||||
|
||||
|
||||
async def load_app_config_async() -> dict[str, Any] | None:
|
||||
"""Load app config from Remnawave API (if configured), with TTL cache.
|
||||
|
||||
Returns None when no Remnawave config is set or API fails.
|
||||
"""
|
||||
global _app_config_cache, _app_config_cache_ts
|
||||
|
||||
ttl = settings.APP_CONFIG_CACHE_TTL
|
||||
if _app_config_cache and (time.monotonic() - _app_config_cache_ts) < ttl:
|
||||
return _app_config_cache
|
||||
|
||||
async with _app_config_lock:
|
||||
# Double-check after acquiring lock
|
||||
if _app_config_cache and (time.monotonic() - _app_config_cache_ts) < ttl:
|
||||
return _app_config_cache
|
||||
|
||||
remnawave_uuid = _get_remnawave_config_uuid()
|
||||
|
||||
if remnawave_uuid:
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
async with service.get_api_client() as api:
|
||||
config = await api.get_subscription_page_config(remnawave_uuid)
|
||||
if config and config.config:
|
||||
raw = dict(config.config)
|
||||
raw['_isRemnawave'] = True
|
||||
_app_config_cache = raw
|
||||
_app_config_cache_ts = time.monotonic()
|
||||
logger.debug('Loaded app config from Remnawave', remnawave_uuid=remnawave_uuid)
|
||||
return raw
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load Remnawave config', error=e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def invalidate_app_config_cache() -> None:
|
||||
"""Clear the cached app config so next call re-fetches from Remnawave.
|
||||
|
||||
Note: This is intentionally sync (called from sync contexts in cabinet API).
|
||||
Setting timestamp to 0 first ensures the fast-path check in load_app_config_async
|
||||
fails immediately, even without acquiring _app_config_lock.
|
||||
"""
|
||||
global _app_config_cache, _app_config_cache_ts
|
||||
_app_config_cache_ts = 0.0
|
||||
_app_config_cache = {}
|
||||
|
||||
|
||||
async def get_apps_for_platform_async(device_type: str, language: str = 'ru') -> list[dict[str, Any]]:
|
||||
"""Get apps for a device type from Remnawave config."""
|
||||
config = await load_app_config_async()
|
||||
if not config:
|
||||
return []
|
||||
|
||||
platforms = config.get('platforms', {})
|
||||
if not isinstance(platforms, dict):
|
||||
return []
|
||||
|
||||
platform_key = _DEVICE_TO_PLATFORM.get(device_type, device_type)
|
||||
platform_data = platforms.get(platform_key)
|
||||
if isinstance(platform_data, dict):
|
||||
apps = platform_data.get('apps', [])
|
||||
return [normalize_app(app) for app in apps if isinstance(app, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def normalize_app(app: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize Remnawave app dict to a unified format with blocks."""
|
||||
return {
|
||||
'id': app.get('id', app.get('name', 'unknown')),
|
||||
'name': app.get('name', ''),
|
||||
'isFeatured': app.get('featured', app.get('isFeatured', False)),
|
||||
'urlScheme': app.get('urlScheme', ''),
|
||||
'isNeedBase64Encoding': app.get('isNeedBase64Encoding', False),
|
||||
'blocks': app.get('blocks', []),
|
||||
'_raw': app,
|
||||
}
|
||||
|
||||
|
||||
def get_platforms_list(config: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Extract available platforms from config for keyboard generation.
|
||||
|
||||
Returns list of {key, displayName, icon_emoji, device_type} sorted by typical order.
|
||||
"""
|
||||
platforms = config.get('platforms', {})
|
||||
if not isinstance(platforms, dict):
|
||||
return []
|
||||
|
||||
# Desired order
|
||||
order = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
|
||||
|
||||
result = []
|
||||
for pk in order:
|
||||
if pk not in platforms:
|
||||
continue
|
||||
pd = platforms[pk]
|
||||
|
||||
if not isinstance(pd, dict) or not pd.get('apps'):
|
||||
continue
|
||||
|
||||
display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'})
|
||||
|
||||
# Get displayName from Remnawave or fallback
|
||||
display_name_data = pd.get('displayName', display['name'])
|
||||
|
||||
result.append(
|
||||
{
|
||||
'key': pk,
|
||||
'displayName': display_name_data,
|
||||
'icon_emoji': display['emoji'],
|
||||
'device_type': _PLATFORM_TO_DEVICE.get(pk, pk),
|
||||
}
|
||||
)
|
||||
|
||||
# Also include any platforms in config not in our order list
|
||||
for pk, pd in platforms.items():
|
||||
if pk in order:
|
||||
continue
|
||||
if not isinstance(pd, dict) or not pd.get('apps'):
|
||||
continue
|
||||
|
||||
display = _PLATFORM_DISPLAY.get(pk, {'name': pk, 'emoji': '📱'})
|
||||
result.append(
|
||||
{
|
||||
'key': pk,
|
||||
'displayName': display.get('name', pk),
|
||||
'icon_emoji': display.get('emoji', '📱'),
|
||||
'device_type': _PLATFORM_TO_DEVICE.get(pk, pk),
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def resolve_button_url(
|
||||
url: str,
|
||||
subscription_url: str | None,
|
||||
crypto_link: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve template variables in button URLs (port of cabinet's _resolve_button_url)."""
|
||||
if not url:
|
||||
return url
|
||||
result = url
|
||||
if subscription_url:
|
||||
result = result.replace('{{SUBSCRIPTION_LINK}}', subscription_url)
|
||||
if crypto_link:
|
||||
result = result.replace('{{HAPP_CRYPT3_LINK}}', crypto_link)
|
||||
result = result.replace('{{HAPP_CRYPT4_LINK}}', crypto_link)
|
||||
return result
|
||||
|
||||
|
||||
def create_deep_link(app: dict[str, Any], subscription_url: str) -> str | None:
|
||||
if not subscription_url:
|
||||
return None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html as html_mod
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from aiogram import types
|
||||
@@ -36,11 +37,10 @@ from app.utils.subscription_utils import (
|
||||
from .common import (
|
||||
_get_addon_discount_percent_for_user,
|
||||
_get_period_hint_from_subscription,
|
||||
format_additional_section,
|
||||
get_apps_for_device,
|
||||
get_apps_for_platform_async,
|
||||
get_device_name,
|
||||
get_step_description,
|
||||
logger,
|
||||
render_guide_blocks,
|
||||
)
|
||||
from .countries import _get_available_countries
|
||||
|
||||
@@ -805,6 +805,7 @@ async def handle_devices_page(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
|
||||
async def handle_single_device_reset(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
texts = get_texts(db_user.language)
|
||||
try:
|
||||
callback_parts = callback.data.split('_')
|
||||
if len(callback_parts) < 4:
|
||||
@@ -828,8 +829,6 @@ async def handle_single_device_reset(callback: types.CallbackQuery, db_user: Use
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
@@ -1271,7 +1270,8 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
|
||||
)
|
||||
return
|
||||
|
||||
apps = get_apps_for_device(device_type, db_user.language)
|
||||
apps = await get_apps_for_platform_async(device_type, db_user.language)
|
||||
|
||||
hide_subscription_link = settings.should_hide_subscription_link()
|
||||
|
||||
if not apps:
|
||||
@@ -1286,7 +1286,7 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
|
||||
other_apps = [app for app in apps if isinstance(app, dict) and app.get('id') and app.get('id') != featured_app_id]
|
||||
|
||||
other_app_names = ', '.join(
|
||||
str(app.get('name')).strip()
|
||||
html_mod.escape(str(app.get('name')).strip())
|
||||
for app in other_apps
|
||||
if isinstance(app.get('name'), str) and app.get('name').strip()
|
||||
)
|
||||
@@ -1304,34 +1304,20 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
|
||||
else:
|
||||
link_section = (
|
||||
texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 <b>Ссылка подписки:</b>')
|
||||
+ f'\n<code>{subscription_link}</code>\n\n'
|
||||
+ f'\n<code>{html_mod.escape(subscription_link)}</code>\n\n'
|
||||
)
|
||||
|
||||
installation_description = get_step_description(featured_app, 'installationStep', db_user.language)
|
||||
add_description = get_step_description(featured_app, 'addSubscriptionStep', db_user.language)
|
||||
connect_description = get_step_description(featured_app, 'connectAndUseStep', db_user.language)
|
||||
additional_before_text = format_additional_section(
|
||||
featured_app.get('additionalBeforeAddSubscriptionStep'),
|
||||
texts,
|
||||
db_user.language,
|
||||
)
|
||||
additional_after_text = format_additional_section(
|
||||
featured_app.get('additionalAfterAddSubscriptionStep'),
|
||||
texts,
|
||||
db_user.language,
|
||||
)
|
||||
|
||||
guide_text = (
|
||||
texts.t(
|
||||
'SUBSCRIPTION_DEVICE_GUIDE_TITLE',
|
||||
'📱 <b>Настройка для {device_name}</b>',
|
||||
).format(device_name=get_device_name(device_type, db_user.language))
|
||||
).format(device_name=html_mod.escape(get_device_name(device_type, db_user.language)))
|
||||
+ '\n\n'
|
||||
+ link_section
|
||||
+ texts.t(
|
||||
'SUBSCRIPTION_DEVICE_FEATURED_APP',
|
||||
'📋 <b>Рекомендуемое приложение:</b> {app_name}',
|
||||
).format(app_name=featured_app.get('name', ''))
|
||||
).format(app_name=html_mod.escape(featured_app.get('name', '')))
|
||||
)
|
||||
|
||||
if other_app_names:
|
||||
@@ -1344,20 +1330,9 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
|
||||
'Нажмите кнопку "Другие приложения" ниже, чтобы выбрать приложение.',
|
||||
)
|
||||
|
||||
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', '<b>Шаг 1 - Установка:</b>')
|
||||
if installation_description:
|
||||
guide_text += f'\n{installation_description}'
|
||||
|
||||
if additional_before_text:
|
||||
guide_text += f'\n\n{additional_before_text}'
|
||||
|
||||
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', '<b>Шаг 2 - Добавление подписки:</b>')
|
||||
if add_description:
|
||||
guide_text += f'\n{add_description}'
|
||||
|
||||
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', '<b>Шаг 3 - Подключение:</b>')
|
||||
if connect_description:
|
||||
guide_text += f'\n{connect_description}'
|
||||
blocks_text = render_guide_blocks(featured_app.get('blocks', []), db_user.language)
|
||||
if blocks_text:
|
||||
guide_text += '\n\n' + blocks_text
|
||||
|
||||
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_HOW_TO_TITLE', '💡 <b>Как подключить:</b>')
|
||||
guide_text += '\n' + '\n'.join(
|
||||
@@ -1381,9 +1356,6 @@ async def handle_device_guide(callback: types.CallbackQuery, db_user: User, db:
|
||||
]
|
||||
)
|
||||
|
||||
if additional_after_text:
|
||||
guide_text += f'\n\n{additional_after_text}'
|
||||
|
||||
await callback.message.edit_text(
|
||||
guide_text,
|
||||
reply_markup=get_connection_guide_keyboard(
|
||||
@@ -1402,7 +1374,7 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db:
|
||||
device_type = callback.data.split('_')[2]
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
apps = get_apps_for_device(device_type, db_user.language)
|
||||
apps = await get_apps_for_platform_async(device_type, db_user.language)
|
||||
|
||||
if not apps:
|
||||
await callback.answer(
|
||||
@@ -1415,7 +1387,7 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db:
|
||||
texts.t(
|
||||
'SUBSCRIPTION_APPS_TITLE',
|
||||
'📱 <b>Приложения для {device_name}</b>',
|
||||
).format(device_name=get_device_name(device_type, db_user.language))
|
||||
).format(device_name=html_mod.escape(get_device_name(device_type, db_user.language)))
|
||||
+ '\n\n'
|
||||
+ texts.t('SUBSCRIPTION_APPS_PROMPT', 'Выберите приложение для подключения:')
|
||||
)
|
||||
@@ -1427,7 +1399,11 @@ async def handle_app_selection(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
|
||||
async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
_, device_type, app_id = callback.data.split('_')
|
||||
parts = callback.data.split('_', 2)
|
||||
if len(parts) < 3:
|
||||
await callback.answer('Invalid callback data', show_alert=True)
|
||||
return
|
||||
_, device_type, app_id = parts
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
@@ -1440,8 +1416,8 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User
|
||||
)
|
||||
return
|
||||
|
||||
apps = get_apps_for_device(device_type, db_user.language)
|
||||
app = next((a for a in apps if a['id'] == app_id), None)
|
||||
apps = await get_apps_for_platform_async(device_type, db_user.language)
|
||||
app = next((a for a in apps if a.get('id') == app_id), None) if apps else None
|
||||
|
||||
if not app:
|
||||
await callback.answer(
|
||||
@@ -1465,53 +1441,33 @@ async def handle_specific_app_guide(callback: types.CallbackQuery, db_user: User
|
||||
else:
|
||||
link_section = (
|
||||
texts.t('SUBSCRIPTION_DEVICE_LINK_TITLE', '🔗 <b>Ссылка подписки:</b>')
|
||||
+ f'\n<code>{subscription_link}</code>\n\n'
|
||||
+ f'\n<code>{html_mod.escape(subscription_link)}</code>\n\n'
|
||||
)
|
||||
|
||||
installation_description = get_step_description(app, 'installationStep', db_user.language)
|
||||
add_description = get_step_description(app, 'addSubscriptionStep', db_user.language)
|
||||
connect_description = get_step_description(app, 'connectAndUseStep', db_user.language)
|
||||
additional_before_text = format_additional_section(
|
||||
app.get('additionalBeforeAddSubscriptionStep'),
|
||||
texts,
|
||||
db_user.language,
|
||||
)
|
||||
additional_after_text = format_additional_section(
|
||||
app.get('additionalAfterAddSubscriptionStep'),
|
||||
texts,
|
||||
db_user.language,
|
||||
)
|
||||
|
||||
guide_text = (
|
||||
texts.t(
|
||||
'SUBSCRIPTION_SPECIFIC_APP_TITLE',
|
||||
'📱 <b>{app_name} - {device_name}</b>',
|
||||
).format(app_name=app.get('name', ''), device_name=get_device_name(device_type, db_user.language))
|
||||
).format(
|
||||
app_name=html_mod.escape(app.get('name', '')),
|
||||
device_name=html_mod.escape(get_device_name(device_type, db_user.language)),
|
||||
)
|
||||
+ '\n\n'
|
||||
+ link_section
|
||||
)
|
||||
|
||||
guide_text += texts.t('SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE', '<b>Шаг 1 - Установка:</b>')
|
||||
if installation_description:
|
||||
guide_text += f'\n{installation_description}'
|
||||
|
||||
if additional_before_text:
|
||||
guide_text += f'\n\n{additional_before_text}'
|
||||
|
||||
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_ADD_TITLE', '<b>Шаг 2 - Добавление подписки:</b>')
|
||||
if add_description:
|
||||
guide_text += f'\n{add_description}'
|
||||
|
||||
guide_text += '\n\n' + texts.t('SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE', '<b>Шаг 3 - Подключение:</b>')
|
||||
if connect_description:
|
||||
guide_text += f'\n{connect_description}'
|
||||
|
||||
if additional_after_text:
|
||||
guide_text += f'\n\n{additional_after_text}'
|
||||
blocks_text = render_guide_blocks(app.get('blocks', []), db_user.language)
|
||||
if blocks_text:
|
||||
guide_text += blocks_text + '\n\n'
|
||||
|
||||
await callback.message.edit_text(
|
||||
guide_text,
|
||||
reply_markup=get_specific_app_keyboard(subscription_link, app, device_type, db_user.language),
|
||||
reply_markup=get_specific_app_keyboard(
|
||||
subscription_link,
|
||||
app,
|
||||
device_type,
|
||||
db_user.language,
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -1543,7 +1499,7 @@ async def show_device_connection_help(callback: types.CallbackQuery, db_user: Us
|
||||
• Нажмите "Подключить"
|
||||
|
||||
<b>🔗 Ваша ссылка подписки:</b>
|
||||
<code>{subscription_link}</code>
|
||||
<code>{html_mod.escape(subscription_link)}</code>
|
||||
|
||||
💡 <b>Совет:</b> Сохраните эту ссылку - она понадобится для подключения новых устройств
|
||||
"""
|
||||
|
||||
@@ -16,6 +16,8 @@ from app.utils.subscription_utils import (
|
||||
get_happ_cryptolink_redirect_link,
|
||||
)
|
||||
|
||||
from .common import get_platforms_list, load_app_config_async, logger
|
||||
|
||||
|
||||
async def handle_connect_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# Проверяем, доступно ли сообщение для редактирования
|
||||
@@ -144,6 +146,33 @@ async def handle_connect_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
parse_mode='HTML',
|
||||
)
|
||||
else:
|
||||
# Guide mode: load config and build dynamic platform keyboard
|
||||
platforms = None
|
||||
try:
|
||||
config = await load_app_config_async()
|
||||
if config:
|
||||
platforms = get_platforms_list(config) or None
|
||||
except Exception as e:
|
||||
logger.warning('Failed to load platforms for guide mode', error=e)
|
||||
|
||||
if not platforms:
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
'GUIDE_CONFIG_NOT_SET',
|
||||
'⚠️ <b>Конфигурация не настроена</b>\n\n'
|
||||
'Администратор ещё не настроил конфигурацию приложений.\n'
|
||||
'Обратитесь к администратору.',
|
||||
),
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data='menu_subscription')],
|
||||
]
|
||||
),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if hide_subscription_link:
|
||||
device_text = texts.t(
|
||||
'SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN',
|
||||
@@ -165,7 +194,9 @@ async def handle_connect_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
).format(subscription_url=subscription_link)
|
||||
|
||||
await callback.message.edit_text(
|
||||
device_text, reply_markup=get_device_selection_keyboard(db_user.language), parse_mode='HTML'
|
||||
device_text,
|
||||
reply_markup=get_device_selection_keyboard(db_user.language, platforms=platforms),
|
||||
parse_mode='HTML',
|
||||
)
|
||||
|
||||
await callback.answer()
|
||||
|
||||
@@ -4119,7 +4119,7 @@ def register_handlers(dp: Dispatcher):
|
||||
|
||||
dp.callback_query.register(handle_app_selection, F.data.startswith('app_list_'))
|
||||
|
||||
dp.callback_query.register(handle_specific_app_guide, F.data.startswith('app_'))
|
||||
dp.callback_query.register(handle_specific_app_guide, F.data.startswith('app_') & ~F.data.startswith('app_list_'))
|
||||
|
||||
dp.callback_query.register(handle_open_subscription_link, F.data == 'open_subscription_link')
|
||||
|
||||
|
||||
@@ -65,16 +65,25 @@ def _apply_promo_discount(price: int, discount_percent: int) -> int:
|
||||
|
||||
|
||||
def _get_user_period_discount(db_user: User, period_days: int) -> int:
|
||||
"""Получает скидку пользователя на период из промогруппы."""
|
||||
promo_group = getattr(db_user, 'promo_group', None)
|
||||
|
||||
if promo_group:
|
||||
discount = promo_group.get_discount_percent('period', period_days)
|
||||
if discount > 0:
|
||||
return discount
|
||||
"""Получает скидку пользователя на период из промогруппы + промо-оффер (стекинг).
|
||||
|
||||
Возвращает итоговый процент скидки после последовательного применения
|
||||
скидки промогруппы и персональной скидки промо-оффера.
|
||||
"""
|
||||
promo_group = db_user.get_primary_promo_group()
|
||||
group_discount = promo_group.get_discount_percent('period', period_days) if promo_group else 0
|
||||
personal_discount = get_user_active_promo_discount_percent(db_user)
|
||||
return personal_discount
|
||||
|
||||
if group_discount <= 0 and personal_discount <= 0:
|
||||
return 0
|
||||
|
||||
# Стекинг: применяем последовательно (как в кабинете)
|
||||
# price * (1 - group/100) * (1 - personal/100)
|
||||
# Вычисляем эффективный общий процент
|
||||
remaining = (100 - group_discount) * (100 - personal_discount)
|
||||
effective_discount = 100 - remaining // 100
|
||||
|
||||
return effective_discount
|
||||
|
||||
|
||||
def format_tariffs_list_text(
|
||||
@@ -2702,11 +2711,12 @@ async def show_instant_switch_list(
|
||||
return
|
||||
|
||||
# Рассчитываем оставшиеся дни
|
||||
now = datetime.now(UTC)
|
||||
remaining_days = 0
|
||||
if subscription.end_date:
|
||||
remaining_days = max(0, (subscription.end_date - datetime.now(UTC)).days)
|
||||
remaining_days = max(0, (subscription.end_date - now).days)
|
||||
|
||||
if remaining_days == 0:
|
||||
if not subscription.end_date or subscription.end_date <= now:
|
||||
await callback.message.edit_text(
|
||||
'❌ <b>Переключение недоступно</b>\n\n'
|
||||
'У вашей подписки не осталось активных дней.\n'
|
||||
|
||||
+35
-15
@@ -890,7 +890,9 @@ async def handle_ticket_reply(message: types.Message, state: FSMContext, db_user
|
||||
|
||||
# Уведомить админов об ответе пользователя
|
||||
logger.info('Attempting to notify admins about ticket reply #', ticket_id=ticket_id)
|
||||
await notify_admins_about_ticket_reply(ticket, reply_text, db)
|
||||
await notify_admins_about_ticket_reply(
|
||||
ticket, reply_text, db, media_file_id=media_file_id, media_type=media_type
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error adding ticket reply', error=e)
|
||||
@@ -995,14 +997,11 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
)
|
||||
return
|
||||
|
||||
# Получаем язык пользователя для локализации заголовков в уведомлении
|
||||
# и формируем удобный текст уведомления для админов
|
||||
get_texts(settings.DEFAULT_LANGUAGE)
|
||||
title = (ticket.title or '').strip()
|
||||
if len(title) > 60:
|
||||
title = title[:57] + '...'
|
||||
|
||||
# Загрузим пользователя, чтобы отобразить реальный Telegram ID и username
|
||||
try:
|
||||
user = await get_user_by_id(db, ticket.user_id)
|
||||
except Exception:
|
||||
@@ -1011,6 +1010,18 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—'
|
||||
username_display = (user.username or 'отсутствует') if user else 'отсутствует'
|
||||
|
||||
# Загружаем первое сообщение для получения медиа и превью текста
|
||||
first_message = await TicketMessageCRUD.get_first_message(db, ticket.id)
|
||||
media_file_id = None
|
||||
media_type = None
|
||||
message_preview = ''
|
||||
if first_message:
|
||||
media_file_id = first_message.media_file_id if first_message.has_media else None
|
||||
media_type = first_message.media_type if first_message.has_media else None
|
||||
msg_text = (first_message.message_text or '').strip()
|
||||
if msg_text:
|
||||
message_preview = msg_text[:200] + '...' if len(msg_text) > 200 else msg_text
|
||||
|
||||
notification_text = (
|
||||
f'🎫 <b>НОВЫЙ ТИКЕТ</b>\n\n'
|
||||
f'🆔 <b>ID:</b> <code>{ticket.id}</code>\n'
|
||||
@@ -1018,13 +1029,13 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
f'🆔 <b>ID:</b> <code>{telegram_id_display}</code>\n'
|
||||
f'📱 <b>Username:</b> @{username_display}\n'
|
||||
f'📝 <b>Заголовок:</b> {title or "—"}\n'
|
||||
f'📅 <b>Создан:</b> {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n'
|
||||
)
|
||||
|
||||
# Клавиатура с быстрыми действиями для админов в топике
|
||||
# Отправляем через общий сервис админ-уведомлений (поддерживает топики)
|
||||
# bot доступен из Dispatcher в middlewares; безопаснее взять из уже используемого контекста
|
||||
# Здесь используем lazy импорт из maintenance_service, где хранится бот
|
||||
if message_preview:
|
||||
notification_text += f'\n📩 <b>Сообщение:</b>\n{message_preview}\n'
|
||||
|
||||
notification_text += f'\n📅 <b>Создан:</b> {format_local_datetime(ticket.created_at, "%d.%m.%Y %H:%M")}\n'
|
||||
|
||||
from app.services.maintenance_service import maintenance_service
|
||||
|
||||
bot = maintenance_service._bot or None
|
||||
@@ -1033,12 +1044,21 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
|
||||
return
|
||||
|
||||
service = AdminNotificationService(bot)
|
||||
await service.send_ticket_event_notification(notification_text, None)
|
||||
await service.send_ticket_event_notification(
|
||||
notification_text, None, media_file_id=media_file_id, media_type=media_type
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error notifying admins about new ticket', error=e)
|
||||
|
||||
|
||||
async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db: AsyncSession):
|
||||
async def notify_admins_about_ticket_reply(
|
||||
ticket: Ticket,
|
||||
reply_text: str,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
media_file_id: str | None = None,
|
||||
media_type: str | None = None,
|
||||
):
|
||||
"""Уведомить админов об ответе пользователя на тикет"""
|
||||
logger.info('notify_admins_about_ticket_reply called for ticket #', ticket_id=ticket.id)
|
||||
try:
|
||||
@@ -1052,7 +1072,6 @@ async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db:
|
||||
if len(title) > 60:
|
||||
title = title[:57] + '...'
|
||||
|
||||
# Загрузим пользователя
|
||||
try:
|
||||
user = await get_user_by_id(db, ticket.user_id)
|
||||
except Exception:
|
||||
@@ -1061,8 +1080,7 @@ async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db:
|
||||
telegram_id_display = (user.telegram_id or user.email or f'#{user.id}') if user else '—'
|
||||
username_display = (user.username or 'отсутствует') if user else 'отсутствует'
|
||||
|
||||
# Обрезаем текст ответа для уведомления
|
||||
reply_preview = reply_text[:150] + '...' if len(reply_text) > 150 else reply_text
|
||||
reply_preview = reply_text[:200] + '...' if len(reply_text) > 200 else reply_text
|
||||
|
||||
notification_text = (
|
||||
f'💬 <b>ОТВЕТ НА ТИКЕТ</b>\n\n'
|
||||
@@ -1082,7 +1100,9 @@ async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db:
|
||||
return
|
||||
|
||||
service = AdminNotificationService(bot)
|
||||
result = await service.send_ticket_event_notification(notification_text, None)
|
||||
result = await service.send_ticket_event_notification(
|
||||
notification_text, None, media_file_id=media_file_id, media_type=media_type
|
||||
)
|
||||
logger.info('Ticket # reply notification sent', ticket_id=ticket.id, result=result)
|
||||
except Exception as e:
|
||||
logger.error('Error notifying admins about ticket reply', error=e)
|
||||
|
||||
@@ -217,6 +217,18 @@ def get_admin_settings_submenu_keyboard(language: str = 'ru') -> InlineKeyboardM
|
||||
callback_data='admin_faq',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_SETTINGS_REQUIRED_CHANNELS', '📢 Обязательные каналы'),
|
||||
callback_data='reqch:list',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=_t(texts, 'ADMIN_SETTINGS_APP_CONFIG', '📱 Конфиг приложений'),
|
||||
callback_data='admin_remna_config',
|
||||
)
|
||||
],
|
||||
[InlineKeyboardButton(text=texts.BACK, callback_data='admin_panel')],
|
||||
]
|
||||
)
|
||||
|
||||
+171
-223
@@ -154,61 +154,6 @@ async def get_main_menu_keyboard_async(
|
||||
)
|
||||
|
||||
|
||||
def _get_localized_value(values, language: str, default_language: str = 'en') -> str:
|
||||
if not isinstance(values, dict):
|
||||
return ''
|
||||
|
||||
candidates = []
|
||||
normalized_language = (language or '').strip().lower()
|
||||
|
||||
if normalized_language:
|
||||
candidates.append(normalized_language)
|
||||
if '-' in normalized_language:
|
||||
candidates.append(normalized_language.split('-')[0])
|
||||
|
||||
default_language = (default_language or '').strip().lower()
|
||||
if default_language and default_language not in candidates:
|
||||
candidates.append(default_language)
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
value = values.get(candidate)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
|
||||
for value in values.values():
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def _build_additional_buttons(additional_section, language: str) -> list[InlineKeyboardButton]:
|
||||
if not isinstance(additional_section, dict):
|
||||
return []
|
||||
|
||||
buttons = additional_section.get('buttons')
|
||||
if not isinstance(buttons, list):
|
||||
return []
|
||||
|
||||
localized_buttons: list[InlineKeyboardButton] = []
|
||||
|
||||
for button in buttons:
|
||||
if not isinstance(button, dict):
|
||||
continue
|
||||
|
||||
button_text = _get_localized_value(button.get('buttonText'), language)
|
||||
button_link = button.get('buttonLink')
|
||||
|
||||
if not button_text or not button_link:
|
||||
continue
|
||||
|
||||
localized_buttons.append(InlineKeyboardButton(text=button_text, url=button_link))
|
||||
|
||||
return localized_buttons
|
||||
|
||||
|
||||
_LANGUAGE_DISPLAY_NAMES = {
|
||||
'ru': '🇷🇺 Русский',
|
||||
'ru-ru': '🇷🇺 Русский',
|
||||
@@ -275,22 +220,47 @@ def get_privacy_policy_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeybo
|
||||
|
||||
|
||||
def get_channel_sub_keyboard(
|
||||
channel_link: str | None,
|
||||
channels: list[dict] | str | None = None,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
"""Subscription keyboard for required channels.
|
||||
|
||||
Supports Bot API 9.4 colored buttons via ``style`` parameter:
|
||||
- subscribed channels → green (``style='success'``)
|
||||
- unsubscribed channels → blue (``style='primary'``)
|
||||
|
||||
Args:
|
||||
channels: List of dicts with 'channel_link', 'title', and optional
|
||||
'is_subscribed' keys, OR a string (legacy single channel_link).
|
||||
language: Locale code for button text.
|
||||
"""
|
||||
texts = get_texts(language)
|
||||
buttons: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
if channel_link:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
|
||||
url=channel_link,
|
||||
)
|
||||
]
|
||||
)
|
||||
if isinstance(channels, str):
|
||||
# Legacy: single channel link string
|
||||
if channels:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
|
||||
url=channels,
|
||||
style='primary',
|
||||
)
|
||||
]
|
||||
)
|
||||
elif isinstance(channels, list):
|
||||
for ch in channels:
|
||||
link = ch.get('channel_link')
|
||||
title = ch.get('title')
|
||||
is_subscribed = ch.get('is_subscribed', False)
|
||||
if link:
|
||||
if is_subscribed:
|
||||
label = f'✅ {title}' if title else '✅'
|
||||
buttons.append([InlineKeyboardButton(text=label, url=link, style='success')])
|
||||
else:
|
||||
label = title or texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться')
|
||||
buttons.append([InlineKeyboardButton(text=label, url=link, style='primary')])
|
||||
|
||||
buttons.append(
|
||||
[
|
||||
@@ -1599,7 +1569,35 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_freekassa_enabled():
|
||||
if settings.is_freekassa_sbp_enabled():
|
||||
sbp_name = settings.get_freekassa_sbp_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_FREEKASSA_SBP', f'📱 {sbp_name}'),
|
||||
callback_data=_build_callback('freekassa_sbp'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if settings.is_freekassa_card_enabled():
|
||||
card_name = settings.get_freekassa_card_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('PAYMENT_FREEKASSA_CARD', f'💳 {card_name}'),
|
||||
callback_data=_build_callback('freekassa_card'),
|
||||
)
|
||||
]
|
||||
)
|
||||
has_direct_payment_methods = True
|
||||
|
||||
if (
|
||||
settings.is_freekassa_enabled()
|
||||
and not settings.is_freekassa_sbp_enabled()
|
||||
and not settings.is_freekassa_card_enabled()
|
||||
):
|
||||
freekassa_name = settings.get_freekassa_display_name()
|
||||
keyboard.append(
|
||||
[
|
||||
@@ -2270,35 +2268,35 @@ def get_manage_countries_keyboard(
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def get_device_selection_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
def get_device_selection_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
platforms: list[dict] | None = None,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.config import settings
|
||||
from app.handlers.subscription.common import get_localized_value
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('DEVICE_GUIDE_IOS', '📱 iOS (iPhone/iPad)'), callback_data='device_guide_ios'
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('DEVICE_GUIDE_ANDROID', '🤖 Android'), callback_data='device_guide_android'
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('DEVICE_GUIDE_WINDOWS', '💻 Windows'), callback_data='device_guide_windows'
|
||||
),
|
||||
InlineKeyboardButton(text=texts.t('DEVICE_GUIDE_MAC', '🎯 macOS'), callback_data='device_guide_mac'),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('DEVICE_GUIDE_ANDROID_TV', '📺 Android TV'), callback_data='device_guide_tv'
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('DEVICE_GUIDE_APPLE_TV', '📺 Apple TV'), callback_data='device_guide_appletv'
|
||||
),
|
||||
],
|
||||
]
|
||||
keyboard: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
if platforms:
|
||||
row: list[InlineKeyboardButton] = []
|
||||
for p in platforms:
|
||||
display_name = p.get('displayName', p['key'])
|
||||
if isinstance(display_name, dict):
|
||||
display_name = get_localized_value(display_name, language)
|
||||
emoji = p.get('icon_emoji', '📱')
|
||||
device_type = p.get('device_type', p['key'])
|
||||
btn = InlineKeyboardButton(
|
||||
text=f'{emoji} {display_name}',
|
||||
callback_data=f'device_guide_{device_type}',
|
||||
)
|
||||
row.append(btn)
|
||||
if len(row) == 2:
|
||||
keyboard.append(row)
|
||||
row = []
|
||||
if row:
|
||||
keyboard.append(row)
|
||||
|
||||
if settings.CONNECT_BUTTON_MODE == 'guide':
|
||||
keyboard.append(
|
||||
@@ -2322,64 +2320,84 @@ def get_connection_guide_keyboard(
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
has_other_apps: bool = False,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.handlers.subscription import create_deep_link
|
||||
from app.handlers.subscription.common import create_deep_link, get_localized_value, resolve_button_url
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard = []
|
||||
keyboard: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
if 'installationStep' in app and 'buttons' in app['installationStep']:
|
||||
app_buttons = []
|
||||
for button in app['installationStep']['buttons']:
|
||||
button_text = _get_localized_value(button.get('buttonText'), language)
|
||||
button_link = button.get('buttonLink')
|
||||
|
||||
if not button_text or not button_link:
|
||||
for block in app.get('blocks', []):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
for btn in block.get('buttons', []):
|
||||
if not isinstance(btn, dict):
|
||||
continue
|
||||
btn_type = btn.get('type', '')
|
||||
btn_text = btn.get('text', {})
|
||||
if isinstance(btn_text, dict):
|
||||
btn_text = get_localized_value(btn_text, language)
|
||||
if not btn_text:
|
||||
continue
|
||||
|
||||
app_buttons.append(InlineKeyboardButton(text=f'📥 {button_text}', url=button_link))
|
||||
if len(app_buttons) == 2:
|
||||
keyboard.append(app_buttons)
|
||||
app_buttons = []
|
||||
btn_url = btn.get('url', '') or btn.get('link', '')
|
||||
resolved_url = btn.get('resolvedUrl', '')
|
||||
|
||||
if app_buttons:
|
||||
keyboard.append(app_buttons)
|
||||
|
||||
additional_before_buttons = _build_additional_buttons(
|
||||
app.get('additionalBeforeAddSubscriptionStep'),
|
||||
language,
|
||||
)
|
||||
|
||||
for button in additional_before_buttons:
|
||||
keyboard.append([button])
|
||||
|
||||
connect_link = create_deep_link(app, subscription_url)
|
||||
|
||||
if connect_link:
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
url=connect_link,
|
||||
)
|
||||
elif settings.is_happ_cryptolink_mode():
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data='open_subscription_link',
|
||||
)
|
||||
else:
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
url=subscription_url,
|
||||
)
|
||||
|
||||
keyboard.append([connect_button])
|
||||
|
||||
additional_after_buttons = _build_additional_buttons(
|
||||
app.get('additionalAfterAddSubscriptionStep'),
|
||||
language,
|
||||
)
|
||||
|
||||
for button in additional_after_buttons:
|
||||
keyboard.append([button])
|
||||
if btn_type == 'externalLink':
|
||||
if btn_url:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'📥 {btn_text}',
|
||||
url=btn_url,
|
||||
style='primary',
|
||||
)
|
||||
]
|
||||
)
|
||||
elif btn_type == 'subscriptionLink':
|
||||
url = resolved_url or resolve_button_url(btn_url, subscription_url)
|
||||
deep_link = create_deep_link(app.get('_raw', app), subscription_url)
|
||||
final_url = deep_link or url or subscription_url
|
||||
if final_url:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
url=final_url,
|
||||
style='success',
|
||||
)
|
||||
]
|
||||
)
|
||||
elif settings.is_happ_cryptolink_mode():
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data='open_subscription_link',
|
||||
style='success',
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
url=subscription_url,
|
||||
style='success',
|
||||
)
|
||||
]
|
||||
)
|
||||
elif btn_type == 'copyButton':
|
||||
url = resolved_url or resolve_button_url(btn_url, subscription_url)
|
||||
if url:
|
||||
keyboard.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'📋 {btn_text}',
|
||||
url=url,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if has_other_apps:
|
||||
keyboard.append(
|
||||
@@ -2441,90 +2459,20 @@ def get_app_selection_keyboard(device_type: str, apps: list, language: str = DEF
|
||||
|
||||
|
||||
def get_specific_app_keyboard(
|
||||
subscription_url: str, app: dict, device_type: str, language: str = DEFAULT_LANGUAGE
|
||||
subscription_url: str,
|
||||
app: dict,
|
||||
device_type: str,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
) -> InlineKeyboardMarkup:
|
||||
from app.handlers.subscription import create_deep_link
|
||||
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard = []
|
||||
|
||||
if 'installationStep' in app and 'buttons' in app['installationStep']:
|
||||
app_buttons = []
|
||||
for button in app['installationStep']['buttons']:
|
||||
button_text = _get_localized_value(button.get('buttonText'), language)
|
||||
button_link = button.get('buttonLink')
|
||||
|
||||
if not button_text or not button_link:
|
||||
continue
|
||||
|
||||
app_buttons.append(InlineKeyboardButton(text=f'📥 {button_text}', url=button_link))
|
||||
if len(app_buttons) == 2:
|
||||
keyboard.append(app_buttons)
|
||||
app_buttons = []
|
||||
|
||||
if app_buttons:
|
||||
keyboard.append(app_buttons)
|
||||
|
||||
additional_before_buttons = _build_additional_buttons(
|
||||
app.get('additionalBeforeAddSubscriptionStep'),
|
||||
# Reuse the connection guide keyboard logic — same buttons, just always shows "Other apps"
|
||||
return get_connection_guide_keyboard(
|
||||
subscription_url,
|
||||
app,
|
||||
device_type,
|
||||
language,
|
||||
has_other_apps=True,
|
||||
)
|
||||
|
||||
for button in additional_before_buttons:
|
||||
keyboard.append([button])
|
||||
|
||||
connect_link = create_deep_link(app, subscription_url)
|
||||
|
||||
if connect_link:
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
url=connect_link,
|
||||
)
|
||||
elif settings.is_happ_cryptolink_mode():
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
callback_data='open_subscription_link',
|
||||
)
|
||||
else:
|
||||
connect_button = InlineKeyboardButton(
|
||||
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
|
||||
url=subscription_url,
|
||||
)
|
||||
|
||||
keyboard.append([connect_button])
|
||||
|
||||
additional_after_buttons = _build_additional_buttons(
|
||||
app.get('additionalAfterAddSubscriptionStep'),
|
||||
language,
|
||||
)
|
||||
|
||||
for button in additional_after_buttons:
|
||||
keyboard.append([button])
|
||||
|
||||
keyboard.extend(
|
||||
[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('OTHER_APPS_BUTTON', '📋 Другие приложения'), callback_data=f'app_list_{device_type}'
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CHOOSE_ANOTHER_DEVICE', '📱 Выбрать другое устройство'),
|
||||
callback_data='subscription_connect',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_TO_SUBSCRIPTION', '⬅️ К подписке'), callback_data='menu_subscription'
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
@@ -663,6 +663,7 @@
|
||||
"ADMIN_SETTINGS_MAINTENANCE": "🔧 Maintenance",
|
||||
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Privacy policy",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Public offer",
|
||||
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 Required channels",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Manage Remnawave, monitoring and other settings:",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **System settings**\n\n",
|
||||
"ADMIN_SQUAD_ADD_ALL": "👥 Add all users",
|
||||
@@ -930,9 +931,10 @@
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
|
||||
"CHANNEL_CHECK_NOT_SUBSCRIBED": "You are not subscribed to all required channels. Please subscribe and try again.",
|
||||
"CHANNEL_REQUIRED_TEXT": "Please subscribe to the required channels and then press the button below.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Please subscribe to all required channels first!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
|
||||
"CHECK_STATUS_BUTTON": "📊 Check status",
|
||||
"CHECK_STATUS_NO_CHANGES": "Status has not changed",
|
||||
@@ -1431,10 +1433,12 @@
|
||||
"SUBSCRIPTION_NO_SERVERS": "No servers",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Your subscription has been paused because you left a required channel.\n\nSubscribe to all channels to restore VPN access.",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
|
||||
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Your subscription has been restored!\n\nThank you for subscribing to the channels. VPN is active again.",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Settings",
|
||||
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Subscription settings</b>\n\n📊 <b>Current parameters:</b>\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:",
|
||||
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
|
||||
|
||||
@@ -666,6 +666,7 @@
|
||||
"ADMIN_SETTINGS_MAINTENANCE": "🔧 تعمیرات",
|
||||
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ حریم خصوصی",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 شرایط استفاده",
|
||||
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 کانالهای اجباری",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "مدیریت Remnawave، مانیتورینگ و تنظیمات:",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **تنظیمات سیستم**",
|
||||
"ADMIN_SETTINGS_TARIFFS": "📦 تعرفهها",
|
||||
@@ -951,9 +952,10 @@
|
||||
"CHANGE_DEVICES_TITLE": "📱 تغییر تعداد دستگاه",
|
||||
"CHANGE_TARIFF_BUTTON": "📦 تعرفه",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ عضو شدم",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 برای استفاده از ربات در کانال خبری عضو شوید، سپس دکمه زیر بزنید.",
|
||||
"CHANNEL_CHECK_NOT_SUBSCRIBED": "شما هنوز در همه کانالها عضو نشدهاید! لطفاً عضو شوید و دوباره امتحان کنید.",
|
||||
"CHANNEL_REQUIRED_TEXT": "لطفاً در کانالهای اجباری عضو شوید و سپس دکمه زیر را فشار دهید.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "📢 عضویت در کانال",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ هنوز عضو کانال نشدهاید!",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "لطفاً ابتدا در همه کانالهای اجباری عضو شوید!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ ممنون از عضویت",
|
||||
"CHECK_STATUS_BUTTON": "📊 بررسی وضعیت",
|
||||
"CHECK_STATUS_NO_CHANGES": "وضعیت تغییر نکرده",
|
||||
@@ -1452,10 +1454,12 @@
|
||||
"SUBSCRIPTION_NO_SERVERS": "❌ سروری در دسترس نیست",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "📱 <b>اشتراک شما</b>\n\n📊 وضعیت: {status}\n📅 اعتبار: {expiry}\n📈 ترافیک: {traffic}\n📱 دستگاهها: {devices}\n🌍 سرورها: {servers}",
|
||||
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 موجودی: {balance}\n📱 اشتراک: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 اطلاعات اشتراک\n🎭 نوع: {subscription_type}\n📈 ترافیک: {traffic}\n🌍 سرورها: {servers}\n📱 دستگاهها: {devices}\n🌍 کشورها: {countries}",
|
||||
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "اشتراک شما متوقف شده است زیرا از کانال اجباری خارج شدید.\n\nبرای بازیابی دسترسی VPN در همه کانالها عضو شوید.",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ تخفیف اضافی {percent}% فعال شد.\n\nبا سایر تخفیفها جمع میشود!",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ تخفیف اضافی {percent}%: -{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "\n⏳ اعتبار تخفیف: {time_left} <code>{bar}</code>",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 اشتراک خریداری شد!",
|
||||
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "اشتراک شما بازیابی شد!\n\nبا تشکر از عضویت در کانالها. VPN دوباره فعال است.",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ تنظیمات",
|
||||
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>تنظیمات اشتراک</b>",
|
||||
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ تنظیمات فقط برای اشتراک پولی",
|
||||
|
||||
@@ -666,6 +666,7 @@
|
||||
"ADMIN_SETTINGS_MAINTENANCE": "🔧 Техработы",
|
||||
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Политика конф.",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публичная оферта",
|
||||
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 Обязательные каналы",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Управление Remnawave, мониторингом и другими настройками:",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Настройки системы**\n\n",
|
||||
"ADMIN_SETTINGS_TARIFFS": "📦 Тарифы",
|
||||
@@ -951,9 +952,10 @@
|
||||
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
|
||||
"CHANGE_TARIFF_BUTTON": "📦 Тариф",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
|
||||
"CHANNEL_CHECK_NOT_SUBSCRIBED": "Вы ещё не подписались на все каналы! Подпишитесь и попробуйте снова.",
|
||||
"CHANNEL_REQUIRED_TEXT": "Пожалуйста, подпишитесь на обязательные каналы и нажмите кнопку ниже.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Пожалуйста, подпишитесь на все обязательные каналы!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
|
||||
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
|
||||
"CHECK_STATUS_NO_CHANGES": "Статус не изменился",
|
||||
@@ -1452,10 +1454,12 @@
|
||||
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Ваша подписка приостановлена, так как вы отписались от обязательного канала.\n\nПодпишитесь на все каналы для восстановления доступа к VPN.",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
|
||||
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Ваша подписка восстановлена!\n\nСпасибо за подписку на каналы. VPN снова активен.",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки",
|
||||
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Настройки подписки</b>\n\n📊 <b>Текущие параметры:</b>\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:",
|
||||
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
|
||||
|
||||
@@ -593,6 +593,7 @@
|
||||
"ADMIN_SETTINGS_MAINTENANCE": "🔧 Техроботи",
|
||||
"ADMIN_SETTINGS_PRIVACY_POLICY": "🛡️ Політика конф.",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER": "📄 Публічна оферта",
|
||||
"ADMIN_SETTINGS_REQUIRED_CHANNELS": "📢 Обов'язкові канали",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION": "Керування Remnawave, моніторингом та іншими налаштуваннями:",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE": "⚙️ **Налаштування системи**\n\n",
|
||||
"ADMIN_SQUAD_ADD_ALL": "👥 Додати всіх користувачів",
|
||||
@@ -871,9 +872,10 @@
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Кількість пристроїв збільшено!\n\n 📱 Було: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Зміна кількості пристроїв",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ Я підписався",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Для використання бота підпишіться на канал новин, а потім натисніть кнопку нижче.",
|
||||
"CHANNEL_CHECK_NOT_SUBSCRIBED": "Ви ще не підписалися на всі канали! Підпишіться і спробуйте знову.",
|
||||
"CHANNEL_REQUIRED_TEXT": "Будь ласка, підпишіться на обов'язкові канали та натисніть кнопку нижче.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Підписатися",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Ви не підписалися на канал!",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Будь ласка, підпишіться на всі обов'язкові канали!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Дякуємо за підписку",
|
||||
"CHECK_STATUS_BUTTON": "📊 Перевірити статус",
|
||||
"CHECK_STATUS_NO_CHANGES": "Статус не змінився",
|
||||
@@ -1362,10 +1364,12 @@
|
||||
"SUBSCRIPTION_NO_SERVERS": "Немає серверів",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Вашу підписку призупинено, оскільки ви відписались від обов'язкового каналу.\n\nПідпишіться на всі канали для відновлення доступу до VPN.",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активовано дод. знижку {percent}%. \n\nСумується з іншими знижками!",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Дод. знижка {percent}%: -{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Знижка діє ще: {time_left}\n<code>{bar}</code>",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Підписку успішно придбано!",
|
||||
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Вашу підписку відновлено!\n\nДякуємо за підписку на канали. VPN знову активний.",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Налаштування підписки",
|
||||
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Налаштування підписки</b>\n\n📊 <b>Поточні параметри:</b>\n🌐 Країн: {countries_count}\n📈 Трафік: {traffic_used} / {traffic_limit}\n📱 Пристрої: {devices_used} / {devices_limit}\n\nОберіть що хочете змінити:",
|
||||
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Налаштування доступні лише для платних підписок",
|
||||
|
||||
@@ -592,6 +592,7 @@
|
||||
"ADMIN_SETTINGS_MAINTENANCE":"🔧维护",
|
||||
"ADMIN_SETTINGS_PRIVACY_POLICY":"🛡️隐私政策",
|
||||
"ADMIN_SETTINGS_PUBLIC_OFFER":"📄公开服务条款",
|
||||
"ADMIN_SETTINGS_REQUIRED_CHANNELS":"📢必订频道",
|
||||
"ADMIN_SETTINGS_SUBMENU_DESCRIPTION":"管理Remnawave、监控和其他设置:",
|
||||
"ADMIN_SETTINGS_SUBMENU_TITLE":"⚙️**系统设置**\n\n",
|
||||
"ADMIN_SQUAD_ADD_ALL":"👥添加所有用户",
|
||||
@@ -869,9 +870,10 @@
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE":"\n ✅设备数量已增加!\n\n 📱之前:{old_count}→现在:{new_count}\n 💰已扣除:{amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE":"📱更改设备数量",
|
||||
"CHANNEL_CHECK_BUTTON":"✅我已订阅",
|
||||
"CHANNEL_REQUIRED_TEXT":"🔒要使用机器人,请订阅新闻频道,然后点击下方按钮。",
|
||||
"CHANNEL_CHECK_NOT_SUBSCRIBED":"您尚未订阅所有必需频道!请订阅后重试。",
|
||||
"CHANNEL_REQUIRED_TEXT":"请订阅所有必需频道,然后点击下方按钮。",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON":"🔗订阅",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT":"❌您没有订阅该频道!",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT":"请先订阅所有必需频道!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS":"✅感谢您的订阅",
|
||||
"CHECK_STATUS_BUTTON":"📊检查状态",
|
||||
"CHECK_STATUS_NO_CHANGES":"状态未更改",
|
||||
@@ -1360,10 +1362,12 @@
|
||||
"SUBSCRIPTION_NO_SERVERS":"没有服务器",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
|
||||
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
|
||||
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE":"由于您退出了必需频道,您的订阅已暂停。\n\n请订阅所有频道以恢复VPN访问。",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
|
||||
"SUBSCRIPTION_PURCHASED":"🎉订阅已成功购买!",
|
||||
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE":"您的订阅已恢复!\n\n感谢您订阅频道。VPN已重新激活。",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON":"⚙️订阅设置",
|
||||
"SUBSCRIPTION_SETTINGS_OVERVIEW":"⚙️<b>订阅设置</b>\n\n📊<b>当前参数:</b>\n🌐国家:{countries_count}\n📈流量:{traffic_used}/{traffic_limit}\n📱设备:{devices_used}/{devices_limit}\n\n请选择要更改的内容:",
|
||||
"SUBSCRIPTION_SETTINGS_PAID_ONLY":"⚠️设置仅适用于付费订阅",
|
||||
@@ -1693,10 +1697,12 @@
|
||||
"SUBSCRIPTION_NO_SERVERS":"没有服务器",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
|
||||
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
|
||||
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE":"由于您退出了必需频道,您的订阅已暂停。\n\n请订阅所有频道以恢复VPN访问。",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
|
||||
"SUBSCRIPTION_PURCHASED":"🎉订阅已成功购买!",
|
||||
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE":"您的订阅已恢复!\n\n感谢您订阅频道。VPN已重新激活。",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON":"⚙️订阅设置",
|
||||
"SUBSCRIPTION_SETTINGS_OVERVIEW":"⚙️<b>订阅设置</b>\n\n📊<b>当前参数:</b>\n🌐国家:{countries_count}\n📈流量:{traffic_used}/{traffic_limit}\n📱设备:{devices_used}/{devices_limit}\n\n请选择要更改的内容:",
|
||||
"SUBSCRIPTION_SETTINGS_PAID_ONLY":"⚠️设置仅适用于付费订阅",
|
||||
|
||||
@@ -222,6 +222,13 @@ class AuthMiddleware(BaseMiddleware):
|
||||
except (InterfaceError, OperationalError) as conn_err:
|
||||
# Соединение закрылось (таймаут после долгой операции) - просто логируем
|
||||
logger.warning('⚠️ Соединение с БД закрыто после обработки, пропускаем commit', conn_err=conn_err)
|
||||
except Exception as commit_err:
|
||||
# Transaction aborted (e.g. handler swallowed a ProgrammingError) — rollback
|
||||
logger.warning('⚠️ Не удалось commit после обработки, rollback', commit_err=commit_err)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
except (InterfaceError, OperationalError) as conn_err:
|
||||
|
||||
+200
-200
@@ -2,11 +2,9 @@ from collections.abc import Awaitable, Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
import structlog
|
||||
from aiogram import BaseMiddleware, Bot, types
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
|
||||
|
||||
@@ -20,69 +18,67 @@ from app.keyboards.inline import get_channel_sub_keyboard
|
||||
from app.localization.loader import DEFAULT_LANGUAGE
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.cache import cache
|
||||
from app.utils.check_reg_process import is_registration_process
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Ключ для хранения pending_start_payload в Redis (резервный механизм)
|
||||
# Redis key prefix and TTL for pending /start payload backup
|
||||
REDIS_PAYLOAD_KEY_PREFIX = 'pending_start_payload:'
|
||||
REDIS_PAYLOAD_TTL = 3600 # 1 час
|
||||
REDIS_PAYLOAD_TTL = 3600 # 1 hour
|
||||
|
||||
|
||||
async def save_pending_payload_to_redis(telegram_id: int, payload: str) -> bool:
|
||||
"""Сохраняет pending_start_payload в Redis напрямую (резервный механизм)."""
|
||||
"""Save pending_start_payload to Redis via the shared cache singleton."""
|
||||
try:
|
||||
redis_client = aioredis.from_url(settings.REDIS_URL)
|
||||
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
||||
await redis_client.set(key, payload, ex=REDIS_PAYLOAD_TTL)
|
||||
await redis_client.aclose()
|
||||
logger.info(
|
||||
"💾 [Redis fallback] Сохранен payload '' для пользователя", payload=payload, telegram_id=telegram_id
|
||||
)
|
||||
return True
|
||||
result = await cache.set(key, payload, expire=REDIS_PAYLOAD_TTL)
|
||||
if result:
|
||||
logger.info('Saved pending payload to Redis', payload=payload, telegram_id=telegram_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error('❌ [Redis fallback] Ошибка сохранения payload для', telegram_id=telegram_id, e=e)
|
||||
logger.error('Failed to save payload to Redis', telegram_id=telegram_id, error=e)
|
||||
return False
|
||||
|
||||
|
||||
async def get_pending_payload_from_redis(telegram_id: int) -> str | None:
|
||||
"""Получает pending_start_payload из Redis (резервный механизм)."""
|
||||
"""Get pending_start_payload from Redis via the shared cache singleton."""
|
||||
try:
|
||||
redis_client = aioredis.from_url(settings.REDIS_URL)
|
||||
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
||||
payload = await redis_client.get(key)
|
||||
await redis_client.aclose()
|
||||
if payload:
|
||||
return payload.decode('utf-8') if isinstance(payload, bytes) else payload
|
||||
return None
|
||||
return await cache.get(key)
|
||||
except Exception as e:
|
||||
logger.debug('❌ [Redis fallback] Ошибка получения payload для', telegram_id=telegram_id, e=e)
|
||||
logger.debug('Failed to get payload from Redis', telegram_id=telegram_id, error=e)
|
||||
return None
|
||||
|
||||
|
||||
async def delete_pending_payload_from_redis(telegram_id: int) -> None:
|
||||
"""Удаляет pending_start_payload из Redis."""
|
||||
"""Delete pending_start_payload from Redis via the shared cache singleton."""
|
||||
try:
|
||||
redis_client = aioredis.from_url(settings.REDIS_URL)
|
||||
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
||||
await redis_client.delete(key)
|
||||
await redis_client.aclose()
|
||||
await cache.delete(key)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Middleware для проверки подписки на канал.
|
||||
ОПТИМИЗИРОВАНО: создаёт максимум одну сессию БД на запрос.
|
||||
"""Middleware for checking required channel subscriptions.
|
||||
|
||||
OPTIMIZED FOR 100k+ USERS:
|
||||
- Does NOT call Telegram API directly in the hot path
|
||||
- Reads from Redis cache (TTL 600s) -> PostgreSQL -> rate-limited API fallback
|
||||
- Updated in real-time via ChatMemberUpdated events
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.BAD_MEMBER_STATUS = (ChatMemberStatus.LEFT, ChatMemberStatus.KICKED, ChatMemberStatus.RESTRICTED)
|
||||
self.GOOD_MEMBER_STATUS = (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR)
|
||||
logger.info('🔧 ChannelCheckerMiddleware инициализирован')
|
||||
logger.info('ChannelCheckerMiddleware initialized (multi-channel mode)')
|
||||
|
||||
@staticmethod
|
||||
def _any_channel_has_disable_flag(channels: list[dict]) -> bool:
|
||||
"""Check if any channel in the list has disable-on-leave flags set."""
|
||||
return any(ch.get('disable_trial_on_leave', True) or ch.get('disable_paid_on_leave', False) for ch in channels)
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
@@ -90,6 +86,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
# Runtime check (supports toggling without restart)
|
||||
if not settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
return await handler(event, data)
|
||||
|
||||
# Fast-path bypasses
|
||||
telegram_id = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
telegram_id = event.from_user.id
|
||||
@@ -100,7 +101,6 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
telegram_id = event.callback_query.from_user.id
|
||||
|
||||
if telegram_id is None:
|
||||
logger.debug('❌ telegram_id не найден, пропускаем')
|
||||
return await handler(event, data)
|
||||
|
||||
# Skip channel check for lightweight UI callbacks (close/delete notifications)
|
||||
@@ -112,110 +112,136 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
):
|
||||
return await handler(event, data)
|
||||
|
||||
# Админам разрешаем пропускать проверку подписки
|
||||
if settings.is_admin(telegram_id):
|
||||
logger.debug(
|
||||
'✅ Пользователь является администратором — пропускаем проверку подписки', telegram_id=telegram_id
|
||||
)
|
||||
return await handler(event, data)
|
||||
|
||||
state: FSMContext = data.get('state')
|
||||
current_state = None
|
||||
|
||||
if state:
|
||||
current_state = await state.get_state()
|
||||
|
||||
is_reg_process = is_registration_process(event, current_state)
|
||||
|
||||
if is_reg_process:
|
||||
logger.debug('✅ Событие разрешено (процесс регистрации), пропускаем проверку')
|
||||
current_state = await state.get_state() if state else None
|
||||
if is_registration_process(event, current_state):
|
||||
return await handler(event, data)
|
||||
|
||||
# Ensure service has bot reference for API fallback
|
||||
bot: Bot = data['bot']
|
||||
if not channel_subscription_service.bot:
|
||||
channel_subscription_service.bot = bot
|
||||
|
||||
channel_id = settings.CHANNEL_SUB_ID
|
||||
# Multi-channel check (Redis -> DB -> API)
|
||||
all_channels = await channel_subscription_service.get_channels_with_status(telegram_id)
|
||||
unsubscribed = [ch for ch in all_channels if not ch.get('is_subscribed', False)]
|
||||
|
||||
if not channel_id:
|
||||
logger.warning('⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку')
|
||||
if not unsubscribed:
|
||||
# All subscribed -- reactivate if needed
|
||||
if self._any_channel_has_disable_flag(all_channels):
|
||||
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
|
||||
return await handler(event, data)
|
||||
|
||||
is_required = settings.CHANNEL_IS_REQUIRED_SUB
|
||||
# User is NOT subscribed to all channels
|
||||
if self._any_channel_has_disable_flag(unsubscribed):
|
||||
await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, all_channels)
|
||||
|
||||
if not is_required:
|
||||
logger.debug('⚠️ Обязательная подписка отключена, пропускаем проверку')
|
||||
return await handler(event, data)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
|
||||
channel_link = self._normalize_channel_link(settings.CHANNEL_LINK, channel_id)
|
||||
if isinstance(event, CallbackQuery) and event.data == 'sub_channel_check':
|
||||
# Rate limit: max 1 check per 5 seconds per user
|
||||
rate_key = f'sub_check_rate:{telegram_id}'
|
||||
if await cache.exists(rate_key):
|
||||
await event.answer()
|
||||
return None
|
||||
await cache.set(rate_key, 1, expire=5)
|
||||
|
||||
if not channel_link:
|
||||
logger.warning('⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта')
|
||||
# Re-check via API for immediate feedback (invalidate cache first)
|
||||
await channel_subscription_service.invalidate_user_cache(telegram_id)
|
||||
|
||||
try:
|
||||
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
||||
all_channels_fresh = await channel_subscription_service.get_channels_with_status(telegram_id)
|
||||
unsubscribed_fresh = [ch for ch in all_channels_fresh if not ch.get('is_subscribed', False)]
|
||||
|
||||
if member.status in self.GOOD_MEMBER_STATUS:
|
||||
# Реактивируем подписку если была отключена из-за отписки от канала
|
||||
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
|
||||
if not unsubscribed_fresh:
|
||||
# Now subscribed to all channels
|
||||
if self._any_channel_has_disable_flag(all_channels_fresh):
|
||||
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
|
||||
return await handler(event, data)
|
||||
if member.status in self.BAD_MEMBER_STATUS:
|
||||
logger.info(
|
||||
'❌ Пользователь не подписан на канал (статус: )', telegram_id=telegram_id, status=member.status
|
||||
)
|
||||
|
||||
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
|
||||
await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, channel_link)
|
||||
# Still not all subscribed — update keyboard with colored buttons
|
||||
# (subscribed = green, unsubscribed = blue) via Bot API 9.4 style
|
||||
user_lang = (
|
||||
event.from_user.language_code.split('-')[0]
|
||||
if event.from_user and event.from_user.language_code
|
||||
else DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
normalized = _normalize_channels(all_channels_fresh)
|
||||
texts = get_texts(user_lang)
|
||||
channel_sub_kb = get_channel_sub_keyboard(normalized, language=user_lang)
|
||||
text = texts.t(
|
||||
'CHANNEL_REQUIRED_TEXT',
|
||||
'🔒 Для использования бота подпишитесь на новостной канал, '
|
||||
'чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!',
|
||||
)
|
||||
|
||||
if isinstance(event, CallbackQuery) and event.data == 'sub_channel_check':
|
||||
await event.answer(
|
||||
'❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.', show_alert=True
|
||||
)
|
||||
return None
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=channel_sub_kb)
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' not in str(e).lower():
|
||||
raise
|
||||
|
||||
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||
logger.warning('⚠️ Неожиданный статус пользователя', telegram_id=telegram_id, status=member.status)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||
await event.answer(
|
||||
texts.t(
|
||||
'CHANNEL_CHECK_NOT_SUBSCRIBED',
|
||||
'You are not subscribed to all required channels. Please subscribe and try again.',
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return None
|
||||
|
||||
except TelegramForbiddenError as e:
|
||||
logger.error('❌ Бот заблокирован в канале', channel_id=channel_id, error=e)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||
except TelegramBadRequest as e:
|
||||
if 'chat not found' in str(e).lower():
|
||||
logger.error('❌ Канал не найден', channel_id=channel_id, error=e)
|
||||
elif 'user not found' in str(e).lower():
|
||||
logger.error('❌ Пользователь не найден', telegram_id=telegram_id, error=e)
|
||||
else:
|
||||
logger.error('❌ Ошибка запроса к каналу', channel_id=channel_id, error=e)
|
||||
await self._capture_start_payload(state, event, bot)
|
||||
return await self._deny_message(event, bot, channel_link, channel_id)
|
||||
except TelegramNetworkError as e:
|
||||
logger.warning('⚠️ Таймаут при проверке подписки на канал', error=e)
|
||||
return await handler(event, data)
|
||||
except Exception as e:
|
||||
logger.error('❌ Неожиданная ошибка при проверке подписки', error=e)
|
||||
return await handler(event, data)
|
||||
return await self._deny_message(event, bot, all_channels)
|
||||
|
||||
# -- _deny_message (multi-channel) -----------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _normalize_channel_link(channel_link: str | None, channel_id: str | None) -> str | None:
|
||||
link = (channel_link or '').strip()
|
||||
async def _deny_message(
|
||||
event: TelegramObject,
|
||||
bot: Bot,
|
||||
channels: list[dict],
|
||||
):
|
||||
user = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = getattr(event, 'from_user', None)
|
||||
elif isinstance(event, Update):
|
||||
if event.message and event.message.from_user:
|
||||
user = event.message.from_user
|
||||
elif event.callback_query and event.callback_query.from_user:
|
||||
user = event.callback_query.from_user
|
||||
|
||||
if link.startswith('@'): # raw username
|
||||
return f'https://t.me/{link.lstrip("@")}'
|
||||
language = DEFAULT_LANGUAGE
|
||||
if user and user.language_code:
|
||||
language = user.language_code.split('-')[0]
|
||||
|
||||
if link and not link.lower().startswith(('http://', 'https://', 'tg://')):
|
||||
return f'https://{link}'
|
||||
normalized = _normalize_channels(channels)
|
||||
|
||||
if link:
|
||||
return link
|
||||
texts = get_texts(language)
|
||||
channel_sub_kb = get_channel_sub_keyboard(normalized, language=language)
|
||||
text = texts.t(
|
||||
'CHANNEL_REQUIRED_TEXT',
|
||||
'🔒 Для использования бота подпишитесь на новостной канал, '
|
||||
'чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!',
|
||||
)
|
||||
|
||||
if channel_id and str(channel_id).startswith('@'):
|
||||
return f'https://t.me/{str(channel_id).lstrip("@")}'
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
return await event.answer(text, reply_markup=channel_sub_kb)
|
||||
if isinstance(event, CallbackQuery):
|
||||
try:
|
||||
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' in str(e).lower():
|
||||
return await event.answer(text, show_alert=True)
|
||||
raise
|
||||
elif isinstance(event, Update) and event.message:
|
||||
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
|
||||
except Exception as e:
|
||||
logger.error('Error sending subscription prompt', error=e)
|
||||
|
||||
return None
|
||||
# -- _capture_start_payload ------------------------------------------------
|
||||
|
||||
async def _capture_start_payload(
|
||||
self,
|
||||
@@ -223,6 +249,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
event: TelegramObject,
|
||||
bot: Bot | None = None,
|
||||
) -> None:
|
||||
"""Save /start payload to FSM + Redis so it can be restored after subscription.
|
||||
|
||||
This preserves referral codes, deep links, and other start parameters
|
||||
when a user is blocked by the channel subscription requirement.
|
||||
"""
|
||||
telegram_id = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
telegram_id = event.from_user.id if event.from_user else None
|
||||
@@ -246,23 +277,21 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
|
||||
payload = parts[1]
|
||||
|
||||
# Сохраняем в FSM state
|
||||
# Save to FSM state
|
||||
if state:
|
||||
state_data = await state.get_data() or {}
|
||||
if state_data.get('pending_start_payload') != payload:
|
||||
state_data['pending_start_payload'] = payload
|
||||
await state.set_data(state_data)
|
||||
logger.info(
|
||||
"💾 Сохранен start payload '' для пользователя (FSM)", payload=payload, telegram_id=telegram_id
|
||||
)
|
||||
logger.info('Saved start payload for user (FSM)', payload=payload, telegram_id=telegram_id)
|
||||
else:
|
||||
logger.warning('⚠️ _capture_start_payload: state=None для пользователя', telegram_id=telegram_id)
|
||||
logger.warning('_capture_start_payload: state=None for user', telegram_id=telegram_id)
|
||||
|
||||
# Также сохраняем в Redis как резерв (на случай потери FSM state)
|
||||
# Also save to Redis as backup (in case FSM state is lost)
|
||||
if telegram_id:
|
||||
await save_pending_payload_to_redis(telegram_id, payload)
|
||||
|
||||
if bot and message.from_user:
|
||||
if bot and message.from_user and state:
|
||||
await self._try_send_campaign_visit_notification(
|
||||
bot,
|
||||
message.from_user,
|
||||
@@ -280,9 +309,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
try:
|
||||
state_data = await state.get_data() or {}
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Не удалось получить данные состояния для уведомления по кампании', payload=payload, error=error
|
||||
)
|
||||
logger.error('Failed to get state data for campaign notification', payload=payload, error=error)
|
||||
return
|
||||
|
||||
if state_data.get('campaign_notification_sent'):
|
||||
@@ -311,16 +338,18 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
await state.update_data(campaign_notification_sent=True)
|
||||
await db.commit()
|
||||
except Exception as error:
|
||||
logger.error('❌ Ошибка отправки уведомления о переходе по кампании', payload=payload, error=error)
|
||||
logger.error('Error sending campaign visit notification', payload=payload, error=error)
|
||||
await db.rollback()
|
||||
|
||||
async def _deactivate_subscription_on_unsubscribe(
|
||||
self, telegram_id: int, bot: Bot, channel_link: str | None
|
||||
) -> None:
|
||||
"""Деактивация подписки при отписке от канала."""
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
return
|
||||
# -- _deactivate (multi-channel) -------------------------------------------
|
||||
|
||||
async def _deactivate_subscription_on_unsubscribe(
|
||||
self,
|
||||
telegram_id: int,
|
||||
bot: Bot,
|
||||
channels: list[dict],
|
||||
) -> None:
|
||||
"""Deactivate subscription when user unsubscribes from required channels."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
@@ -332,15 +361,19 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
if subscription.status != SubscriptionStatus.ACTIVE.value:
|
||||
return
|
||||
|
||||
if settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
pass
|
||||
elif not subscription.is_trial:
|
||||
# Per-channel settings: check if any unsubscribed channel requires deactivation
|
||||
unsubscribed = [ch for ch in channels if not ch.get('is_subscribed', False)]
|
||||
should_disable = any(
|
||||
channel_subscription_service.should_disable_subscription(ch, subscription.is_trial)
|
||||
for ch in unsubscribed
|
||||
)
|
||||
if not should_disable:
|
||||
return
|
||||
|
||||
await deactivate_subscription(db, subscription)
|
||||
sub_type = 'Триальная' if subscription.is_trial else 'Платная'
|
||||
sub_type = 'trial' if subscription.is_trial else 'paid'
|
||||
logger.info(
|
||||
'🚫 подписка пользователя отключена после отписки от канала',
|
||||
'Subscription deactivated after channel unsubscribe',
|
||||
sub_type=sub_type,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
@@ -351,84 +384,83 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
await service.disable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
'❌ Не удалось отключить пользователя RemnaWave',
|
||||
'Failed to disable RemnaWave user',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
api_error=api_error,
|
||||
)
|
||||
|
||||
# Уведомляем пользователя о деактивации
|
||||
# Notify user about deactivation
|
||||
try:
|
||||
normalized = _normalize_channels(channels)
|
||||
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
|
||||
notification_text = texts.t(
|
||||
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
|
||||
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
|
||||
'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.',
|
||||
)
|
||||
channel_kb = get_channel_sub_keyboard(channel_link, language=user.language)
|
||||
channel_kb = get_channel_sub_keyboard(normalized, language=user.language)
|
||||
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
|
||||
except Exception as notify_error:
|
||||
logger.error(
|
||||
'❌ Не удалось отправить уведомление о деактивации пользователю',
|
||||
'Failed to send deactivation notification to user',
|
||||
telegram_id=telegram_id,
|
||||
notify_error=notify_error,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as db_error:
|
||||
logger.error(
|
||||
'❌ Ошибка деактивации подписки пользователя после отписки',
|
||||
'Error deactivating subscription after channel unsubscribe',
|
||||
telegram_id=telegram_id,
|
||||
db_error=db_error,
|
||||
)
|
||||
await db.rollback()
|
||||
|
||||
async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None:
|
||||
"""Реактивация подписки после повторной подписки на канал."""
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
return
|
||||
# -- _reactivate -----------------------------------------------------------
|
||||
|
||||
async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None:
|
||||
"""Reactivate subscription after user subscribes to all required channels."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
user = await get_user_by_telegram_id(db, telegram_id)
|
||||
if not user or not user.subscription:
|
||||
return
|
||||
|
||||
# НЕ реактивируем подписку заблокированным пользователям
|
||||
# Do NOT reactivate for blocked users
|
||||
if user.status == UserStatus.BLOCKED.value:
|
||||
logger.info('🚫 Пропуск реактивации для заблокированного пользователя', telegram_id=telegram_id)
|
||||
logger.info('Skipping reactivation for blocked user', telegram_id=telegram_id)
|
||||
return
|
||||
|
||||
subscription = user.subscription
|
||||
|
||||
# Реактивируем только DISABLED подписки
|
||||
# Only reactivate DISABLED subscriptions
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
return
|
||||
|
||||
# Проверяем что подписка ещё не истекла
|
||||
# Check subscription has not expired
|
||||
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
|
||||
return
|
||||
|
||||
# Реактивируем в БД
|
||||
await reactivate_subscription(db, subscription)
|
||||
sub_type = 'Триальная' if subscription.is_trial else 'Платная'
|
||||
sub_type = 'trial' if subscription.is_trial else 'paid'
|
||||
logger.info(
|
||||
'✅ подписка пользователя реактивирована после подписки на канал',
|
||||
'Subscription reactivated after channel subscribe',
|
||||
sub_type=sub_type,
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
|
||||
# Включаем в RemnaWave
|
||||
# Enable in RemnaWave
|
||||
if user.remnawave_uuid:
|
||||
service = SubscriptionService()
|
||||
try:
|
||||
await service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
'❌ Не удалось включить пользователя RemnaWave',
|
||||
'Failed to enable RemnaWave user',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
api_error=api_error,
|
||||
)
|
||||
|
||||
# Уведомляем пользователя о реактивации
|
||||
# Notify user about reactivation
|
||||
try:
|
||||
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
|
||||
notification_text = texts.t(
|
||||
@@ -438,65 +470,33 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
||||
await bot.send_message(telegram_id, notification_text)
|
||||
except Exception as notify_error:
|
||||
logger.warning(
|
||||
'Не удалось отправить уведомление о реактивации пользователю',
|
||||
'Failed to send reactivation notification to user',
|
||||
telegram_id=telegram_id,
|
||||
notify_error=notify_error,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as db_error:
|
||||
logger.error('❌ Ошибка реактивации подписки пользователя', telegram_id=telegram_id, db_error=db_error)
|
||||
logger.error('Error reactivating subscription', telegram_id=telegram_id, db_error=db_error)
|
||||
await db.rollback()
|
||||
|
||||
@staticmethod
|
||||
async def _deny_message(
|
||||
event: TelegramObject,
|
||||
bot: Bot,
|
||||
channel_link: str | None,
|
||||
channel_id: str | None,
|
||||
):
|
||||
logger.debug('🚫 Отправляем сообщение о необходимости подписки')
|
||||
|
||||
user = None
|
||||
if isinstance(event, (Message, CallbackQuery)):
|
||||
user = getattr(event, 'from_user', None)
|
||||
elif isinstance(event, Update):
|
||||
if event.message and event.message.from_user:
|
||||
user = event.message.from_user
|
||||
elif event.callback_query and event.callback_query.from_user:
|
||||
user = event.callback_query.from_user
|
||||
def _normalize_channel_link(link: str) -> str:
|
||||
"""Normalize channel link: convert @username to https://t.me/username."""
|
||||
if not link:
|
||||
return link
|
||||
link = link.strip()
|
||||
if link.startswith('@'):
|
||||
return f'https://t.me/{link[1:]}'
|
||||
return link
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
if user and user.language_code:
|
||||
language = user.language_code.split('-')[0]
|
||||
|
||||
texts = get_texts(language)
|
||||
channel_sub_kb = get_channel_sub_keyboard(channel_link, language=language)
|
||||
text = texts.t(
|
||||
'CHANNEL_REQUIRED_TEXT',
|
||||
'🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!',
|
||||
)
|
||||
|
||||
if not channel_link and channel_id:
|
||||
channel_hint = None
|
||||
|
||||
if str(channel_id).startswith('@'): # username-based channel id
|
||||
channel_hint = f'@{str(channel_id).lstrip("@")}'
|
||||
|
||||
if channel_hint:
|
||||
text = f'{text}\n\n{channel_hint}'
|
||||
|
||||
try:
|
||||
if isinstance(event, Message):
|
||||
return await event.answer(text, reply_markup=channel_sub_kb)
|
||||
if isinstance(event, CallbackQuery):
|
||||
try:
|
||||
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
|
||||
except TelegramBadRequest as e:
|
||||
if 'message is not modified' in str(e).lower():
|
||||
logger.debug('ℹ️ Сообщение уже содержит текст проверки подписки, пропускаем редактирование')
|
||||
return await event.answer(text, show_alert=True)
|
||||
raise
|
||||
elif isinstance(event, Update) and event.message:
|
||||
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
|
||||
except Exception as e:
|
||||
logger.error('❌ Ошибка при отправке сообщения о подписке', error=e)
|
||||
def _normalize_channels(channels: list[dict]) -> list[dict]:
|
||||
"""Normalize channel links in a list of channel dicts (preserves is_subscribed)."""
|
||||
normalized = []
|
||||
for ch in channels:
|
||||
ch_copy = dict(ch)
|
||||
link = ch_copy.get('channel_link')
|
||||
if link:
|
||||
ch_copy['channel_link'] = _normalize_channel_link(link)
|
||||
normalized.append(ch_copy)
|
||||
return normalized
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Middleware to ignore non-private chat messages.
|
||||
|
||||
When the bot is added as admin to a group or supergroup (including forums
|
||||
with topics), it should silently drop all incoming messages and callback
|
||||
queries from those chats. Only private (DM) interactions are processed.
|
||||
|
||||
Not registered on chat_member — channel_member.py needs ChatMemberUpdated
|
||||
events from groups/channels to track required channel subscriptions.
|
||||
Not registered on pre_checkout_query — no chat context, always private.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.enums import ChatType
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ChatTypeFilterMiddleware(BaseMiddleware):
|
||||
"""Drop messages and callback queries from non-private chats."""
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
chat = None
|
||||
if isinstance(event, Message):
|
||||
chat = event.chat
|
||||
elif isinstance(event, CallbackQuery) and event.message:
|
||||
chat = event.message.chat
|
||||
|
||||
if chat is not None and chat.type != ChatType.PRIVATE:
|
||||
logger.debug(
|
||||
'Dropping non-private chat event',
|
||||
chat_id=chat.id,
|
||||
chat_type=chat.type,
|
||||
)
|
||||
return None
|
||||
|
||||
return await handler(event, data)
|
||||
@@ -240,7 +240,7 @@ class AdminNotificationService:
|
||||
|
||||
return mapping.get(promo_type, f'ℹ️ {promo_type}')
|
||||
|
||||
def _format_campaign_bonus(self, campaign: AdvertisingCampaign) -> list[str]:
|
||||
def _format_campaign_bonus(self, campaign: AdvertisingCampaign, *, tariff_name: str | None = None) -> list[str]:
|
||||
if campaign.is_balance_bonus:
|
||||
return [
|
||||
f'💰 Баланс: {settings.format_price(campaign.balance_bonus_kopeks or 0)}',
|
||||
@@ -249,14 +249,24 @@ class AdminNotificationService:
|
||||
if campaign.is_subscription_bonus:
|
||||
default_devices = getattr(settings, 'DEFAULT_DEVICE_LIMIT', 1)
|
||||
details = [
|
||||
f'📅 Дней подписки: {campaign.subscription_duration_days or 0}',
|
||||
f'📊 Трафик: {campaign.subscription_traffic_gb or 0} ГБ',
|
||||
f'📱 Устройства: {campaign.subscription_device_limit or default_devices}',
|
||||
f'📅 {campaign.subscription_duration_days or 0} дн. '
|
||||
f'• 📊 {campaign.subscription_traffic_gb or 0} ГБ '
|
||||
f'• 📱 {campaign.subscription_device_limit or default_devices} устр.',
|
||||
]
|
||||
if campaign.subscription_squads:
|
||||
details.append(f'🌐 Сквады: {len(campaign.subscription_squads)} шт.')
|
||||
return details
|
||||
|
||||
if campaign.is_tariff_bonus:
|
||||
name = tariff_name or f'ID {campaign.tariff_id}'
|
||||
details = [f'📦 Тариф: <b>{name}</b>']
|
||||
if campaign.tariff_duration_days:
|
||||
details.append(f'📅 Период: {campaign.tariff_duration_days} дней')
|
||||
return details
|
||||
|
||||
if campaign.is_none_bonus:
|
||||
return ['🔗 Только отслеживание']
|
||||
|
||||
return ['ℹ️ Бонусы не предусмотрены']
|
||||
|
||||
async def send_trial_activation_notification(
|
||||
@@ -1035,40 +1045,50 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
try:
|
||||
user_status = '🆕 Новый пользователь' if not user else '👥 Уже зарегистрирован'
|
||||
promo_block = (
|
||||
self._format_promo_group_block(await self._get_user_promo_group(db, user))
|
||||
if user
|
||||
else self._format_promo_group_block(None)
|
||||
)
|
||||
|
||||
full_name = telegram_user.full_name or telegram_user.username or str(telegram_user.id)
|
||||
username = f'@{telegram_user.username}' if telegram_user.username else 'отсутствует'
|
||||
user_status = '🆕 Новый' if not user else '👥 Существующий'
|
||||
|
||||
message_lines = [
|
||||
'📣 <b>ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ</b>',
|
||||
'📣 <b>ПЕРЕХОД ПО РК</b>',
|
||||
'',
|
||||
f'🧾 <b>Кампания:</b> {campaign.name}',
|
||||
f'🆔 ID кампании: {campaign.id}',
|
||||
f'🔗 Start-параметр: <code>{campaign.start_parameter}</code>',
|
||||
f'🧾 {campaign.name} (<code>{campaign.start_parameter}</code>)',
|
||||
'',
|
||||
f'👤 <b>Пользователь:</b> {full_name}',
|
||||
f'🆔 <b>Telegram ID:</b> <code>{telegram_user.id}</code>',
|
||||
f'📱 <b>Username:</b> {username}',
|
||||
user_status,
|
||||
'',
|
||||
promo_block,
|
||||
'',
|
||||
'🎯 <b>Бонус кампании:</b>',
|
||||
f'👤 {full_name} (<code>{telegram_user.id}</code>)',
|
||||
]
|
||||
|
||||
bonus_lines = self._format_campaign_bonus(campaign)
|
||||
if telegram_user.username:
|
||||
message_lines.append(f'📱 @{telegram_user.username}')
|
||||
|
||||
message_lines.append(f'📋 {user_status}')
|
||||
|
||||
# Промогруппа — только если есть
|
||||
if user:
|
||||
promo_group = await self._get_user_promo_group(db, user)
|
||||
if promo_group:
|
||||
message_lines.append(f'🏷️ Промогруппа: {promo_group.name}')
|
||||
|
||||
message_lines.append('')
|
||||
|
||||
# Загружаем название тарифа для tariff-бонуса
|
||||
tariff_name = None
|
||||
if campaign.is_tariff_bonus and campaign.tariff_id:
|
||||
try:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, campaign.tariff_id)
|
||||
if tariff:
|
||||
tariff_name = tariff.name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Бонус кампании
|
||||
bonus_lines = self._format_campaign_bonus(campaign, tariff_name=tariff_name)
|
||||
message_lines.extend(bonus_lines)
|
||||
|
||||
message_lines.extend(
|
||||
[
|
||||
'',
|
||||
f'⏰ <i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>',
|
||||
f'<i>{format_local_datetime(datetime.now(UTC), "%d.%m.%Y %H:%M:%S")}</i>',
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1778,10 +1798,16 @@ class AdminNotificationService:
|
||||
return False
|
||||
|
||||
async def send_ticket_event_notification(
|
||||
self, text: str, keyboard: types.InlineKeyboardMarkup | None = None
|
||||
self,
|
||||
text: str,
|
||||
keyboard: types.InlineKeyboardMarkup | None = None,
|
||||
*,
|
||||
media_file_id: str | None = None,
|
||||
media_type: str | None = None,
|
||||
) -> bool:
|
||||
"""Публичный метод для отправки уведомлений по тикетам в админ-топик.
|
||||
Учитывает настройки включенности в settings.
|
||||
Если передан media_file_id, отправляет медиа в тот же топик вместе с текстом.
|
||||
"""
|
||||
# Respect runtime toggle for admin ticket notifications
|
||||
try:
|
||||
@@ -1797,8 +1823,59 @@ class AdminNotificationService:
|
||||
runtime_enabled=runtime_enabled,
|
||||
)
|
||||
return False
|
||||
|
||||
# Если есть медиа, отправляем фото с текстом как caption (если влезает) или текст + фото
|
||||
if media_file_id and media_type == 'photo':
|
||||
return await self._send_ticket_photo_notification(text, media_file_id, keyboard)
|
||||
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
async def _send_ticket_photo_notification(
|
||||
self,
|
||||
text: str,
|
||||
photo_file_id: str,
|
||||
keyboard: types.InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""Отправить фото с текстом в тикет-топик.
|
||||
Если текст <= 1024 символов — отправляем фото с caption.
|
||||
Иначе — сначала текст, потом фото в тот же топик.
|
||||
"""
|
||||
if not self.chat_id:
|
||||
return False
|
||||
|
||||
thread_id = self.ticket_topic_id or self.topic_id
|
||||
|
||||
try:
|
||||
if len(text) <= 1024:
|
||||
# Фото с caption — всё в одном сообщении
|
||||
photo_kwargs: dict = {
|
||||
'chat_id': self.chat_id,
|
||||
'photo': photo_file_id,
|
||||
'caption': text,
|
||||
'parse_mode': 'HTML',
|
||||
}
|
||||
if thread_id:
|
||||
photo_kwargs['message_thread_id'] = thread_id
|
||||
if keyboard:
|
||||
photo_kwargs['reply_markup'] = keyboard
|
||||
await self.bot.send_photo(**photo_kwargs)
|
||||
else:
|
||||
# Текст отдельно, фото следом в тот же топик
|
||||
await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
photo_kwargs = {
|
||||
'chat_id': self.chat_id,
|
||||
'photo': photo_file_id,
|
||||
}
|
||||
if thread_id:
|
||||
photo_kwargs['message_thread_id'] = thread_id
|
||||
await self.bot.send_photo(**photo_kwargs)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки фото-уведомления тикета', error=e)
|
||||
# Fallback: отправляем хотя бы текст
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
async def send_suspicious_traffic_notification(self, message: str, bot: Bot, topic_id: int | None = None) -> bool:
|
||||
"""
|
||||
Отправляет уведомление о подозрительной активности трафика
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import gzip
|
||||
import html as html_lib
|
||||
import json as json_lib
|
||||
import math
|
||||
import os
|
||||
@@ -575,17 +576,27 @@ class BackupService:
|
||||
table_name = model.__tablename__
|
||||
logger.info('📊 Экспортируем таблицу', table_name=table_name)
|
||||
|
||||
query = select(model)
|
||||
try:
|
||||
query = select(model)
|
||||
|
||||
if model == User:
|
||||
query = query.options(selectinload(User.subscription))
|
||||
elif model == Subscription:
|
||||
query = query.options(selectinload(Subscription.user))
|
||||
elif model == Transaction:
|
||||
query = query.options(selectinload(Transaction.user))
|
||||
if model == User:
|
||||
query = query.options(selectinload(User.subscription))
|
||||
elif model == Subscription:
|
||||
query = query.options(selectinload(Subscription.user))
|
||||
elif model == Transaction:
|
||||
query = query.options(selectinload(Transaction.user))
|
||||
|
||||
result = await db.execute(query)
|
||||
records = result.scalars().all()
|
||||
result = await db.execute(query)
|
||||
records = result.scalars().all()
|
||||
except Exception as table_exc:
|
||||
logger.warning(
|
||||
'⚠️ Ошибка экспорта таблицы, пропускаем',
|
||||
table_name=table_name,
|
||||
error=str(table_exc),
|
||||
)
|
||||
await db.rollback()
|
||||
backup_data[table_name] = []
|
||||
continue
|
||||
|
||||
table_data: list[dict[str, Any]] = []
|
||||
for record in records:
|
||||
@@ -634,19 +645,6 @@ class BackupService:
|
||||
files_dir = staging_dir / 'files'
|
||||
files_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
app_config_path = settings.get_app_config_path()
|
||||
if app_config_path:
|
||||
src = Path(app_config_path)
|
||||
if src.exists():
|
||||
dest = files_dir / src.name
|
||||
await asyncio.to_thread(shutil.copy2, src, dest)
|
||||
files_info.append(
|
||||
{
|
||||
'path': str(src),
|
||||
'relative_path': f'files/{src.name}',
|
||||
}
|
||||
)
|
||||
|
||||
if include_logs and settings.LOG_FILE:
|
||||
log_path = Path(settings.LOG_FILE)
|
||||
if log_path.exists():
|
||||
@@ -1482,46 +1480,10 @@ class BackupService:
|
||||
logger.warning('⚠️ Не удалось очистить таблицу', table_name=table_name, error=e)
|
||||
|
||||
async def _collect_file_snapshots(self) -> dict[str, dict[str, Any]]:
|
||||
snapshots: dict[str, dict[str, Any]] = {}
|
||||
|
||||
app_config_path = settings.get_app_config_path()
|
||||
if app_config_path:
|
||||
path_obj = Path(app_config_path)
|
||||
if path_obj.exists() and path_obj.is_file():
|
||||
try:
|
||||
async with aiofiles.open(path_obj, encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
snapshots['app_config'] = {
|
||||
'path': str(path_obj),
|
||||
'content': content,
|
||||
'modified_at': datetime.fromtimestamp(path_obj.stat().st_mtime, tz=UTC).isoformat(),
|
||||
}
|
||||
logger.info('📁 Добавлен в бекап файл конфигурации', path_obj=path_obj)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка чтения файла конфигурации', path_obj=path_obj, e=e)
|
||||
|
||||
return snapshots
|
||||
return {}
|
||||
|
||||
async def _restore_file_snapshots(self, file_snapshots: dict[str, dict[str, Any]]) -> int:
|
||||
restored_files = 0
|
||||
|
||||
if not file_snapshots:
|
||||
return restored_files
|
||||
|
||||
app_config_snapshot = file_snapshots.get('app_config')
|
||||
if app_config_snapshot:
|
||||
target_path = Path(settings.get_app_config_path())
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
async with aiofiles.open(target_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(app_config_snapshot.get('content', ''))
|
||||
restored_files += 1
|
||||
logger.info('📁 Файл app-config восстановлен по пути', target_path=target_path)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка восстановления файла', target_path=target_path, e=e)
|
||||
|
||||
return restored_files
|
||||
return 0
|
||||
|
||||
async def get_backup_list(self) -> list[dict[str, Any]]:
|
||||
backups = []
|
||||
@@ -1725,7 +1687,8 @@ class BackupService:
|
||||
icons = {'success': '✅', 'error': '❌', 'restore_success': '🔥', 'restore_error': '❌'}
|
||||
|
||||
icon = icons.get(event_type, 'ℹ️')
|
||||
notification_text = f'{icon} <b>СИСТЕМА БЕКАПОВ</b>\n\n{message}'
|
||||
safe_message = html_lib.escape(message) if 'error' in event_type else message
|
||||
notification_text = f'{icon} <b>СИСТЕМА БЕКАПОВ</b>\n\n{safe_message}'
|
||||
|
||||
if file_path:
|
||||
notification_text += f'\n📁 <code>{Path(file_path).name}</code>'
|
||||
|
||||
@@ -27,9 +27,6 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Хранение ссылок на фоновые задачи, чтобы GC не удалил их
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
VALID_MEDIA_TYPES = {'photo', 'video', 'document'}
|
||||
|
||||
@@ -359,15 +356,6 @@ class BroadcastService:
|
||||
# Задержка между батчами для rate limiting
|
||||
await asyncio.sleep(_TG_BATCH_DELAY)
|
||||
|
||||
# Фоновая очистка заблокировавших бота пользователей
|
||||
if blocked_telegram_ids:
|
||||
task = asyncio.create_task(
|
||||
cleanup_blocked_broadcast_users(blocked_telegram_ids),
|
||||
name=f'broadcast-{broadcast_id}-blocked-cleanup',
|
||||
)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
return sent_count, failed_count, blocked_count, False
|
||||
|
||||
def _build_keyboard(self, selected_buttons: list[str] | None) -> InlineKeyboardMarkup | None:
|
||||
@@ -547,8 +535,23 @@ async def cleanup_blocked_broadcast_users(blocked_telegram_ids: list[int]) -> No
|
||||
|
||||
user.status = UserStatus.BLOCKED.value
|
||||
|
||||
# Отключаем активные подписки
|
||||
sub_result = await session.execute(
|
||||
# Проверяем, есть ли активная оплаченная подписка
|
||||
from app.database.crud.subscription import is_active_paid_subscription
|
||||
|
||||
sub_result = await session.execute(select(Subscription).where(Subscription.user_id == user.id))
|
||||
user_subscription = sub_result.scalar_one_or_none()
|
||||
|
||||
if is_active_paid_subscription(user_subscription):
|
||||
logger.info(
|
||||
'⏭️ Пропуск отключения подписки: у пользователя активная оплаченная подписка',
|
||||
telegram_id=telegram_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
await session.commit()
|
||||
continue
|
||||
|
||||
# Отключаем активные подписки (только триальные или истёкшие)
|
||||
active_sub_result = await session.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.user_id == user.id,
|
||||
Subscription.status.in_(
|
||||
@@ -559,7 +562,7 @@ async def cleanup_blocked_broadcast_users(blocked_telegram_ids: list[int]) -> No
|
||||
),
|
||||
)
|
||||
)
|
||||
subscriptions = sub_result.scalars().all()
|
||||
subscriptions = active_sub_result.scalars().all()
|
||||
for sub in subscriptions:
|
||||
sub.status = SubscriptionStatus.DISABLED.value
|
||||
|
||||
|
||||
@@ -56,6 +56,15 @@ class AdvertisingCampaignService:
|
||||
logger.warning('⚠️ Попытка выдать бонус по неактивной кампании', campaign_id=campaign.id)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
# Prevent partner from being attributed to their own campaign
|
||||
if campaign.partner_user_id and campaign.partner_user_id == user.id:
|
||||
logger.info(
|
||||
'Skipping campaign bonus: user is the campaign partner',
|
||||
user_id=user.id,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
return CampaignBonusResult(success=False)
|
||||
|
||||
if campaign.is_balance_bonus:
|
||||
return await self._apply_balance_bonus(db, user, campaign)
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Channel subscription verification service.
|
||||
|
||||
Architecture for 100k+ users:
|
||||
1. ChatMemberUpdated events -> update PostgreSQL (source of truth) + Redis in real-time
|
||||
2. Middleware reads ONLY from Redis/PostgreSQL (never calls Telegram API directly)
|
||||
3. Background reconciliation (~10 req/sec) corrects drift
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from aiogram import Bot
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError, TelegramRetryAfter
|
||||
|
||||
from app.database.crud.required_channel import (
|
||||
get_active_channels,
|
||||
get_user_channel_subs,
|
||||
upsert_user_channel_sub,
|
||||
)
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.utils.cache import ChannelSubCache
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Rate limiting for Telegram API calls
|
||||
_API_SEMAPHORE = asyncio.Semaphore(20) # max 20 concurrent getChatMember calls
|
||||
_API_DELAY = 0.05 # 50ms between calls -> ~20/sec safe rate
|
||||
|
||||
GOOD_STATUSES = (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR)
|
||||
|
||||
# How long a DB record is considered fresh (no API call needed)
|
||||
DB_FRESHNESS_SECONDS = 1800 # 30 min
|
||||
|
||||
|
||||
class ChannelSubscriptionService:
|
||||
"""Centralized service for channel subscription verification."""
|
||||
|
||||
def __init__(self, bot: Bot | None = None):
|
||||
self.bot = bot
|
||||
|
||||
# -- Public API ---------------------------------------------------------------
|
||||
|
||||
async def get_required_channels(self) -> list[dict]:
|
||||
"""Get the list of active required channels (cached)."""
|
||||
cached = await ChannelSubCache.get_required_channels()
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
channels = await get_active_channels(db)
|
||||
result = [
|
||||
{
|
||||
'id': ch.id,
|
||||
'channel_id': ch.channel_id,
|
||||
'channel_link': ch.channel_link,
|
||||
'title': ch.title,
|
||||
'sort_order': ch.sort_order,
|
||||
'disable_trial_on_leave': ch.disable_trial_on_leave,
|
||||
'disable_paid_on_leave': ch.disable_paid_on_leave,
|
||||
}
|
||||
for ch in channels
|
||||
]
|
||||
await ChannelSubCache.set_required_channels(result)
|
||||
return result
|
||||
|
||||
async def get_required_channel_ids(self) -> set[str]:
|
||||
"""Get the set of active required channel_ids (for event filtering)."""
|
||||
channels = await self.get_required_channels()
|
||||
return {ch['channel_id'] for ch in channels}
|
||||
|
||||
async def get_channel_settings(self, channel_id: str) -> dict | None:
|
||||
"""Get per-channel settings for a specific channel (from cache)."""
|
||||
channels = await self.get_required_channels()
|
||||
for ch in channels:
|
||||
if ch['channel_id'] == channel_id:
|
||||
return ch
|
||||
return None
|
||||
|
||||
def should_disable_subscription(self, channel: dict, is_trial: bool) -> bool:
|
||||
"""Check if a channel's settings require subscription deactivation."""
|
||||
if is_trial:
|
||||
return channel.get('disable_trial_on_leave', True)
|
||||
return channel.get('disable_paid_on_leave', False)
|
||||
|
||||
async def check_user_subscriptions(self, telegram_id: int) -> dict[str, bool]:
|
||||
"""Check user subscriptions to all required channels.
|
||||
|
||||
Returns {channel_id: is_member}.
|
||||
Does NOT call Telegram API unless cache miss + stale DB.
|
||||
Uses a SINGLE DB session for all channels (no N+1).
|
||||
"""
|
||||
channels = await self.get_required_channels()
|
||||
return await self._check_user_subscriptions_for_channels(telegram_id, channels)
|
||||
|
||||
async def _check_user_subscriptions_for_channels(
|
||||
self,
|
||||
telegram_id: int,
|
||||
channels: list[dict],
|
||||
) -> dict[str, bool]:
|
||||
"""Internal: check subscriptions for a given list of channels.
|
||||
|
||||
Avoids double-fetching required_channels when called from
|
||||
get_unsubscribed_channels or get_channels_with_status.
|
||||
"""
|
||||
if not channels:
|
||||
return {}
|
||||
|
||||
result: dict[str, bool] = {}
|
||||
channels_needing_db: list[dict] = []
|
||||
|
||||
# Layer 1: Redis cache (single MGET round-trip)
|
||||
all_channel_ids = [ch['channel_id'] for ch in channels]
|
||||
cached_statuses = await ChannelSubCache.get_sub_statuses(telegram_id, all_channel_ids)
|
||||
for ch in channels:
|
||||
channel_id = ch['channel_id']
|
||||
cached = cached_statuses.get(channel_id)
|
||||
if cached is not None:
|
||||
result[channel_id] = cached
|
||||
else:
|
||||
channels_needing_db.append(ch)
|
||||
|
||||
# Layer 2: PostgreSQL (single session for all channels)
|
||||
channels_needing_api: list[dict] = []
|
||||
if channels_needing_db:
|
||||
async with AsyncSessionLocal() as db:
|
||||
subs = await get_user_channel_subs(db, telegram_id)
|
||||
sub_map = {s.channel_id: s for s in subs}
|
||||
|
||||
for ch in channels_needing_db:
|
||||
channel_id = ch['channel_id']
|
||||
sub = sub_map.get(channel_id)
|
||||
|
||||
if sub and sub.checked_at:
|
||||
age = (datetime.now(UTC) - sub.checked_at).total_seconds()
|
||||
if age < DB_FRESHNESS_SECONDS:
|
||||
result[channel_id] = sub.is_member
|
||||
await ChannelSubCache.set_sub_status(telegram_id, channel_id, sub.is_member)
|
||||
continue
|
||||
|
||||
channels_needing_api.append(ch)
|
||||
|
||||
# Layer 3: Rate-limited API calls for channels without fresh data
|
||||
if channels_needing_api and self.bot:
|
||||
async with AsyncSessionLocal() as db:
|
||||
for ch in channels_needing_api:
|
||||
is_member = await self._rate_limited_check(telegram_id, ch['channel_id'])
|
||||
result[ch['channel_id']] = is_member
|
||||
# Write DB first (source of truth), then cache
|
||||
await upsert_user_channel_sub(db, telegram_id, ch['channel_id'], is_member)
|
||||
await ChannelSubCache.set_sub_status(telegram_id, ch['channel_id'], is_member)
|
||||
await db.commit()
|
||||
elif channels_needing_api:
|
||||
# No bot available (e.g., cabinet API context) -- fail-closed
|
||||
logger.warning(
|
||||
'No bot instance for API check -- failing closed',
|
||||
telegram_id=telegram_id,
|
||||
channels=[ch['channel_id'] for ch in channels_needing_api],
|
||||
)
|
||||
for ch in channels_needing_api:
|
||||
result[ch['channel_id']] = False
|
||||
|
||||
return result
|
||||
|
||||
async def is_user_subscribed_to_all(self, telegram_id: int) -> bool:
|
||||
"""Quick check: is user subscribed to ALL required channels?"""
|
||||
subs = await self.check_user_subscriptions(telegram_id)
|
||||
if not subs:
|
||||
return True # No required channels = subscribed
|
||||
return all(subs.values())
|
||||
|
||||
async def get_unsubscribed_channels(self, telegram_id: int) -> list[dict]:
|
||||
"""Get the list of channels the user is NOT subscribed to."""
|
||||
channels = await self.get_required_channels()
|
||||
subs = await self._check_user_subscriptions_for_channels(telegram_id, channels)
|
||||
|
||||
unsubscribed = []
|
||||
for ch in channels:
|
||||
if not subs.get(ch['channel_id'], False):
|
||||
unsubscribed.append(ch)
|
||||
return unsubscribed
|
||||
|
||||
async def get_channels_with_status(self, telegram_id: int) -> list[dict]:
|
||||
"""Get all required channels with per-channel subscription status (for cabinet API)."""
|
||||
channels = await self.get_required_channels()
|
||||
subs = await self._check_user_subscriptions_for_channels(telegram_id, channels)
|
||||
|
||||
result = []
|
||||
for ch in channels:
|
||||
result.append(
|
||||
{
|
||||
'channel_id': ch['channel_id'],
|
||||
'channel_link': ch.get('channel_link'),
|
||||
'title': ch.get('title'),
|
||||
'is_subscribed': subs.get(ch['channel_id'], False),
|
||||
'disable_trial_on_leave': ch.get('disable_trial_on_leave', True),
|
||||
'disable_paid_on_leave': ch.get('disable_paid_on_leave', False),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
async def get_first_channel_id(self) -> str | None:
|
||||
"""Get the first active channel ID (for announcements, contest posts, etc.).
|
||||
|
||||
Channel IDs are always stored as strings in the DB.
|
||||
Telegram API accepts string channel_id in chat_id parameters.
|
||||
"""
|
||||
channels = await self.get_required_channels()
|
||||
if not channels:
|
||||
return None
|
||||
return channels[0]['channel_id']
|
||||
|
||||
# -- Event handlers (called from ChatMemberUpdated router) --------------------
|
||||
|
||||
async def on_user_joined(self, telegram_id: int, channel_id: str) -> None:
|
||||
"""Called when ChatMemberUpdated fires: user subscribed."""
|
||||
logger.info('Channel join event', telegram_id=telegram_id, channel_id=channel_id)
|
||||
# Write DB first (source of truth), then cache
|
||||
async with AsyncSessionLocal() as db:
|
||||
await upsert_user_channel_sub(db, telegram_id, channel_id, True)
|
||||
await db.commit()
|
||||
await ChannelSubCache.set_sub_status(telegram_id, channel_id, True)
|
||||
|
||||
async def on_user_left(self, telegram_id: int, channel_id: str) -> None:
|
||||
"""Called when ChatMemberUpdated fires: user unsubscribed."""
|
||||
logger.info('Channel leave event', telegram_id=telegram_id, channel_id=channel_id)
|
||||
# Write DB first (source of truth), then cache
|
||||
async with AsyncSessionLocal() as db:
|
||||
await upsert_user_channel_sub(db, telegram_id, channel_id, False)
|
||||
await db.commit()
|
||||
await ChannelSubCache.set_sub_status(telegram_id, channel_id, False)
|
||||
|
||||
# -- Channel list management --------------------------------------------------
|
||||
|
||||
async def invalidate_channels_cache(self) -> None:
|
||||
"""Invalidate the channels list cache (call after CRUD)."""
|
||||
await ChannelSubCache.invalidate_channels()
|
||||
|
||||
async def invalidate_user_cache(self, telegram_id: int) -> None:
|
||||
"""Invalidate all cached subscription statuses for a user."""
|
||||
channels = await self.get_required_channels()
|
||||
channel_ids = [ch['channel_id'] for ch in channels]
|
||||
await ChannelSubCache.invalidate_user_channels(telegram_id, channel_ids)
|
||||
|
||||
# -- Rate-limited Telegram API ------------------------------------------------
|
||||
|
||||
async def _rate_limited_check(self, telegram_id: int, channel_id: str) -> bool:
|
||||
"""Check subscription via Telegram API with rate-limiting.
|
||||
|
||||
SECURITY: Fail-closed -- any error returns False (not subscribed).
|
||||
For a VPN access control system, false negatives (temporary denial)
|
||||
are preferable to false positives (unauthorized access).
|
||||
"""
|
||||
async with _API_SEMAPHORE:
|
||||
try:
|
||||
member = await self.bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
||||
await asyncio.sleep(_API_DELAY)
|
||||
return member.status in GOOD_STATUSES
|
||||
except TelegramRetryAfter as e:
|
||||
logger.warning('Rate limited by Telegram', retry_after=e.retry_after, channel_id=channel_id)
|
||||
await asyncio.sleep(e.retry_after)
|
||||
try:
|
||||
member = await self.bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
||||
return member.status in GOOD_STATUSES
|
||||
except Exception:
|
||||
logger.error('Double failure after rate-limit retry', channel_id=channel_id)
|
||||
return False # Fail-closed on double failure
|
||||
except TelegramForbiddenError:
|
||||
logger.critical(
|
||||
'Bot removed/blocked from channel -- all checks will fail-closed',
|
||||
channel_id=channel_id,
|
||||
)
|
||||
return False # Fail-closed -- bot cannot verify membership
|
||||
except TelegramBadRequest as e:
|
||||
if 'user not found' in str(e).lower():
|
||||
return False # User never interacted with bot in that context
|
||||
logger.error('Bad request checking channel', channel_id=channel_id, error=str(e))
|
||||
return False # Fail-closed
|
||||
except TelegramNetworkError:
|
||||
logger.warning('Network error checking channel', channel_id=channel_id)
|
||||
return False # Fail-closed
|
||||
except Exception as e:
|
||||
logger.error('Unexpected error checking channel', channel_id=channel_id, error=str(e))
|
||||
return False # Fail-closed
|
||||
|
||||
|
||||
# Singleton instance (bot is set at startup)
|
||||
channel_subscription_service = ChannelSubscriptionService()
|
||||
@@ -295,13 +295,11 @@ class ContestRotationService:
|
||||
async def _send_channel_announce(self, text: str) -> None:
|
||||
if not self.bot:
|
||||
return
|
||||
channel_id_raw = settings.CHANNEL_SUB_ID
|
||||
if not channel_id_raw:
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
|
||||
channel_id = await channel_subscription_service.get_first_channel_id()
|
||||
if not channel_id:
|
||||
return
|
||||
try:
|
||||
channel_id = int(channel_id_raw)
|
||||
except Exception:
|
||||
channel_id = channel_id_raw
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text='🎲 Играть', callback_data='contests_menu')]]
|
||||
@@ -315,7 +313,7 @@ class ContestRotationService:
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error('Не удалось отправить анонс в канал', channel_id_raw=channel_id_raw, exc=exc)
|
||||
logger.error('Не удалось отправить анонс в канал', channel_id=channel_id, exc=exc)
|
||||
|
||||
async def _broadcast_to_users(self, text: str) -> None:
|
||||
"""Отправляет анонс всем пользователям с активной/триальной подпиской."""
|
||||
|
||||
+225
-159
@@ -4,7 +4,6 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -45,12 +44,13 @@ from app.database.models import (
|
||||
TicketStatus,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
)
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPIError,
|
||||
RemnaWaveUser,
|
||||
TrafficLimitStrategy,
|
||||
UserStatus,
|
||||
UserStatus as RemnaWaveUserStatus,
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.notification_delivery_service import (
|
||||
@@ -71,6 +71,9 @@ from app.utils.timezone import format_local_datetime
|
||||
# Кулдаун между повторными уведомлениями об автоплатеже с недостаточным балансом (6 часов)
|
||||
AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS: int = 21600
|
||||
|
||||
# Размер батча для проверки подписок на каналы (keyset pagination)
|
||||
_CHANNEL_CHECK_BATCH_SIZE: int = 100
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -93,6 +96,7 @@ class MonitoringService:
|
||||
text: str,
|
||||
reply_markup=None,
|
||||
parse_mode: str | None = 'HTML',
|
||||
user: User | None = None,
|
||||
):
|
||||
"""Отправляет сообщение, добавляя логотип при необходимости."""
|
||||
if not self.bot:
|
||||
@@ -103,6 +107,11 @@ class MonitoringService:
|
||||
logger.debug('Пропуск уведомления: chat_id не указан (email-пользователь)')
|
||||
return None
|
||||
|
||||
# Skip blocked/deleted users to save Telegram rate limits
|
||||
if user and user.status in (UserStatus.BLOCKED.value, UserStatus.DELETED.value):
|
||||
logger.debug('Пропуск уведомления: пользователь недоступен', user_id=user.id, status=user.status)
|
||||
return None
|
||||
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and (text is None or len(text) <= 1000):
|
||||
try:
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
@@ -144,11 +153,9 @@ class MonitoringService:
|
||||
)
|
||||
return any(marker in message for marker in unreachable_markers)
|
||||
|
||||
def _handle_unreachable_user(self, user: User, error: Exception, context: str) -> bool:
|
||||
async def _handle_unreachable_user(self, user: User, error: Exception, context: str) -> bool:
|
||||
if isinstance(error, TelegramForbiddenError):
|
||||
logger.warning(
|
||||
'⚠️ Пользователь недоступен : бот заблокирован', telegram_id=user.telegram_id, context=context
|
||||
)
|
||||
logger.warning('⚠️ Пользователь недоступен: бот заблокирован', telegram_id=user.telegram_id, context=context)
|
||||
return True
|
||||
|
||||
if isinstance(error, TelegramBadRequest) and self._is_unreachable_error(error):
|
||||
@@ -338,7 +345,7 @@ class MonitoringService:
|
||||
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_active else UserStatus.EXPIRED,
|
||||
status=RemnaWaveUserStatus.ACTIVE if is_active else RemnaWaveUserStatus.EXPIRED,
|
||||
expire_at=subscription.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
|
||||
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
|
||||
@@ -395,7 +402,7 @@ class MonitoringService:
|
||||
or user_key in all_processed_users
|
||||
):
|
||||
logger.debug(
|
||||
'🔄 Пропускаем дублирование для пользователя на дней',
|
||||
'Уведомление уже отправлено, пропускаем',
|
||||
user_identifier=user_identifier,
|
||||
days=days,
|
||||
)
|
||||
@@ -469,6 +476,7 @@ class MonitoringService:
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(Subscription.user)
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.promo_group),
|
||||
selectinload(Subscription.user)
|
||||
@@ -481,6 +489,7 @@ class MonitoringService:
|
||||
Subscription.is_trial == True,
|
||||
Subscription.end_date <= threshold_time,
|
||||
Subscription.end_date > datetime.now(UTC),
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -515,187 +524,242 @@ class MonitoringService:
|
||||
logger.error('Ошибка проверки истекающих тестовых подписок', error=e)
|
||||
|
||||
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
|
||||
from app.database.crud.subscription import is_recently_updated_by_webhook
|
||||
"""Background reconciliation of channel subscriptions (rate-limited).
|
||||
|
||||
Processes subscriptions in batches using keyset pagination to avoid
|
||||
loading all trial subscriptions into memory at once. Each batch gets
|
||||
a fresh DB session to avoid holding a connection pool slot for hours.
|
||||
|
||||
When CHANNEL_REQUIRED_FOR_ALL is True, checks ALL active subscriptions
|
||||
(not just trials). Otherwise only checks trial subscriptions.
|
||||
"""
|
||||
from app.database.crud.subscription import is_active_paid_subscription, is_recently_updated_by_webhook
|
||||
|
||||
if not settings.CHANNEL_IS_REQUIRED_SUB:
|
||||
return
|
||||
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
||||
logger.debug('ℹ️ Проверка отписок от канала отключена — деактивация триальных подписок не требуется')
|
||||
return
|
||||
|
||||
channel_id = settings.CHANNEL_SUB_ID
|
||||
if not channel_id:
|
||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||
logger.debug('Channel unsubscribe check disabled')
|
||||
return
|
||||
|
||||
if not self.bot:
|
||||
logger.debug('⚠️ Пропускаем проверку подписки на канал — бот недоступен')
|
||||
logger.debug('Skipping channel subscription check - bot unavailable')
|
||||
return
|
||||
|
||||
from app.database.crud.required_channel import upsert_user_channel_sub
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
from app.utils.cache import ChannelSubCache
|
||||
|
||||
channels = await channel_subscription_service.get_required_channels()
|
||||
if not channels:
|
||||
return
|
||||
|
||||
# Ensure bot is set on service
|
||||
if not channel_subscription_service.bot:
|
||||
channel_subscription_service.bot = self.bot
|
||||
|
||||
try:
|
||||
now = datetime.now(UTC)
|
||||
notifications_allowed = (
|
||||
NotificationSettingsService.are_notifications_globally_enabled()
|
||||
and NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
|
||||
)
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
Subscription.is_trial.is_(True),
|
||||
Subscription.end_date > now,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
subscriptions = result.scalars().all()
|
||||
if not subscriptions:
|
||||
return
|
||||
|
||||
disabled_count = 0
|
||||
restored_count = 0
|
||||
checked_count = 0
|
||||
last_id = 0
|
||||
|
||||
for subscription in subscriptions:
|
||||
user = subscription.user
|
||||
if not user or not user.telegram_id:
|
||||
continue
|
||||
# Build the trial/all filter based on CHANNEL_REQUIRED_FOR_ALL setting
|
||||
from sqlalchemy import true as sa_true
|
||||
|
||||
try:
|
||||
member = await self.bot.get_chat_member(channel_id, user.telegram_id)
|
||||
member_status = member.status
|
||||
is_member = member_status in (
|
||||
ChatMemberStatus.MEMBER,
|
||||
ChatMemberStatus.ADMINISTRATOR,
|
||||
ChatMemberStatus.CREATOR,
|
||||
)
|
||||
except TelegramForbiddenError as error:
|
||||
logger.error(
|
||||
'❌ Не удалось проверить подписку пользователя на канал : бот заблокирован',
|
||||
telegram_id=user.telegram_id,
|
||||
channel_id=channel_id,
|
||||
error=error,
|
||||
)
|
||||
continue
|
||||
except TelegramBadRequest as error:
|
||||
# PARTICIPANT_ID_INVALID - пользователь никогда не был в канале, это нормально
|
||||
logger.warning(
|
||||
'⚠️ Ошибка Telegram при проверке подписки пользователя',
|
||||
telegram_id=user.telegram_id,
|
||||
error=error,
|
||||
)
|
||||
continue
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Неожиданная ошибка при проверке подписки пользователя',
|
||||
telegram_id=user.telegram_id,
|
||||
error=error,
|
||||
)
|
||||
continue
|
||||
is_trial_filter = sa_true() if settings.CHANNEL_REQUIRED_FOR_ALL else Subscription.is_trial.is_(True)
|
||||
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.is_trial and not is_member:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск деактивации trial подписки : обновлена вебхуком недавно',
|
||||
subscription_id=subscription.id,
|
||||
while True:
|
||||
# Fresh session per batch to avoid long-running connections
|
||||
async with AsyncSessionLocal() as batch_db:
|
||||
result = await batch_db.execute(
|
||||
select(Subscription)
|
||||
.join(Subscription.user)
|
||||
.options(
|
||||
selectinload(Subscription.user),
|
||||
selectinload(Subscription.tariff),
|
||||
)
|
||||
continue
|
||||
subscription = await deactivate_subscription(db, subscription)
|
||||
disabled_count += 1
|
||||
logger.info(
|
||||
'🚫 Триальная подписка пользователя (ID) отключена из-за отписки от канала',
|
||||
telegram_id=user.telegram_id,
|
||||
subscription_id=subscription.id,
|
||||
.where(
|
||||
and_(
|
||||
Subscription.id > last_id,
|
||||
is_trial_filter,
|
||||
Subscription.end_date > now,
|
||||
Subscription.status.in_(
|
||||
[
|
||||
SubscriptionStatus.ACTIVE.value,
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
]
|
||||
),
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
)
|
||||
)
|
||||
.order_by(Subscription.id)
|
||||
.limit(_CHANNEL_CHECK_BATCH_SIZE)
|
||||
)
|
||||
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
'❌ Не удалось отключить пользователя RemnaWave',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
api_error=api_error,
|
||||
subscriptions = result.scalars().all()
|
||||
if not subscriptions:
|
||||
break
|
||||
|
||||
last_id = subscriptions[-1].id
|
||||
|
||||
for subscription in subscriptions:
|
||||
user = subscription.user
|
||||
if not user or not user.telegram_id:
|
||||
continue
|
||||
|
||||
# Existing guard: skip if recently updated by webhook
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Skipping subscription: recently updated by webhook',
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
continue
|
||||
|
||||
checked_count += 1
|
||||
|
||||
# Rate-limited check for ALL channels
|
||||
all_subscribed = True
|
||||
for ch in channels:
|
||||
is_member = await channel_subscription_service._rate_limited_check(
|
||||
user.telegram_id, ch['channel_id']
|
||||
)
|
||||
# Update DB + cache
|
||||
await upsert_user_channel_sub(batch_db, user.telegram_id, ch['channel_id'], is_member)
|
||||
await ChannelSubCache.set_sub_status(user.telegram_id, ch['channel_id'], is_member)
|
||||
|
||||
if not is_member:
|
||||
all_subscribed = False
|
||||
|
||||
# DEACTIVATE: was active, now not subscribed to all
|
||||
if subscription.status == SubscriptionStatus.ACTIVE.value and not all_subscribed:
|
||||
# Guard: always skip paid subscriptions (user paid money)
|
||||
if is_active_paid_subscription(subscription):
|
||||
continue
|
||||
|
||||
subscription = await deactivate_subscription(batch_db, subscription)
|
||||
disabled_count += 1
|
||||
logger.info(
|
||||
'Subscription deactivated (channel unsubscribe)',
|
||||
telegram_id=user.telegram_id,
|
||||
subscription_id=subscription.id,
|
||||
is_trial=subscription.is_trial,
|
||||
)
|
||||
|
||||
if notifications_allowed:
|
||||
if not await notification_sent(
|
||||
db,
|
||||
user.id,
|
||||
subscription.id,
|
||||
'trial_channel_unsubscribed',
|
||||
):
|
||||
sent = await self._send_trial_channel_unsubscribed_notification(user)
|
||||
if sent:
|
||||
await record_notification(
|
||||
db,
|
||||
if user.remnawave_uuid:
|
||||
try:
|
||||
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
'Failed to disable RemnaWave user',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
api_error=api_error,
|
||||
)
|
||||
|
||||
if notifications_allowed:
|
||||
if not await notification_sent(
|
||||
batch_db,
|
||||
user.id,
|
||||
subscription.id,
|
||||
'trial_channel_unsubscribed',
|
||||
):
|
||||
sent = await self._send_trial_channel_unsubscribed_notification(user)
|
||||
if sent:
|
||||
await record_notification(
|
||||
batch_db,
|
||||
user.id,
|
||||
subscription.id,
|
||||
'trial_channel_unsubscribed',
|
||||
)
|
||||
|
||||
# REACTIVATE: was disabled, now subscribed to all
|
||||
elif subscription.status == SubscriptionStatus.DISABLED.value and all_subscribed:
|
||||
# Guard: traffic limit exhausted
|
||||
if (
|
||||
subscription.traffic_limit_gb
|
||||
and subscription.traffic_used_gb is not None
|
||||
and subscription.traffic_used_gb >= subscription.traffic_limit_gb
|
||||
):
|
||||
logger.debug(
|
||||
'Skipping reactivation: traffic exhausted',
|
||||
subscription_id=subscription.id,
|
||||
traffic_used=subscription.traffic_used_gb,
|
||||
traffic_limit=subscription.traffic_limit_gb,
|
||||
)
|
||||
elif subscription.status == SubscriptionStatus.DISABLED.value and subscription.is_trial and is_member:
|
||||
if is_recently_updated_by_webhook(subscription):
|
||||
logger.debug(
|
||||
'Пропуск реактивации trial подписки : обновлена вебхуком недавно',
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
continue
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
restored_count += 1
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
'✅ Триальная подписка пользователя (ID) восстановлена после повторной подписки на канал',
|
||||
telegram_id=user.telegram_id,
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
# Guard: disabled by webhook, not by monitoring
|
||||
if (
|
||||
subscription.last_webhook_update_at
|
||||
and subscription.updated_at
|
||||
and subscription.last_webhook_update_at
|
||||
>= subscription.updated_at - timedelta(seconds=10)
|
||||
):
|
||||
logger.debug(
|
||||
'Skipping reactivation: disabled by RemnaWave panel',
|
||||
subscription_id=subscription.id,
|
||||
last_webhook_at=subscription.last_webhook_update_at,
|
||||
updated_at=subscription.updated_at,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
if user.remnawave_uuid:
|
||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
||||
else:
|
||||
await self.subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
'❌ Не удалось обновить RemnaWave пользователя',
|
||||
telegram_id=user.telegram_id,
|
||||
api_error=api_error,
|
||||
)
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
restored_count += 1
|
||||
|
||||
await clear_notification_by_type(
|
||||
db,
|
||||
subscription.id,
|
||||
'trial_channel_unsubscribed',
|
||||
)
|
||||
logger.info(
|
||||
'Subscription restored (channel resubscribe)',
|
||||
telegram_id=user.telegram_id,
|
||||
subscription_id=subscription.id,
|
||||
is_trial=subscription.is_trial,
|
||||
)
|
||||
|
||||
try:
|
||||
if user.remnawave_uuid:
|
||||
await self.subscription_service.update_remnawave_user(batch_db, subscription)
|
||||
else:
|
||||
await self.subscription_service.create_remnawave_user(batch_db, subscription)
|
||||
except Exception as api_error:
|
||||
logger.error(
|
||||
'Failed to update RemnaWave user',
|
||||
telegram_id=user.telegram_id,
|
||||
api_error=api_error,
|
||||
)
|
||||
|
||||
await clear_notification_by_type(
|
||||
batch_db,
|
||||
subscription.id,
|
||||
'trial_channel_unsubscribed',
|
||||
)
|
||||
|
||||
# Commit all changes for this batch
|
||||
await batch_db.commit()
|
||||
|
||||
if disabled_count or restored_count:
|
||||
check_scope = 'all' if settings.CHANNEL_REQUIRED_FOR_ALL else 'trial'
|
||||
await self._log_monitoring_event(
|
||||
db,
|
||||
'trial_channel_subscription_check',
|
||||
(
|
||||
f'Проверено {len(subscriptions)} триальных подписок: отключено {disabled_count}, '
|
||||
f'восстановлено {restored_count}'
|
||||
f'Checked {checked_count} {check_scope} subscriptions: '
|
||||
f'disabled {disabled_count}, restored {restored_count}'
|
||||
),
|
||||
{
|
||||
'checked': len(subscriptions),
|
||||
'checked': checked_count,
|
||||
'disabled': disabled_count,
|
||||
'restored': restored_count,
|
||||
'scope': check_scope,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка проверки подписки на канал для триальных пользователей', error=error)
|
||||
logger.error('Error checking channel subscriptions', error=error)
|
||||
|
||||
async def _check_expired_subscription_followups(self, db: AsyncSession):
|
||||
if not NotificationSettingsService.are_notifications_globally_enabled():
|
||||
@@ -1142,7 +1206,7 @@ class MonitoringService:
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, 'уведомление об истечении подписки'):
|
||||
if await self._handle_unreachable_user(user, exc, 'уведомление об истечении подписки'):
|
||||
return True
|
||||
logger.error(
|
||||
'Ошибка Telegram API при отправке уведомления об истечении подписки пользователю',
|
||||
@@ -1209,7 +1273,7 @@ class MonitoringService:
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, 'уведомление об истекающей подписке'):
|
||||
if await self._handle_unreachable_user(user, exc, 'уведомление об истекающей подписке'):
|
||||
return True
|
||||
logger.error(
|
||||
'Ошибка Telegram API при отправке уведомления об истечении подписки пользователю',
|
||||
@@ -1257,11 +1321,12 @@ class MonitoringService:
|
||||
text=message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
user=user,
|
||||
)
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, 'уведомление о завершении тестовой подписки'):
|
||||
if await self._handle_unreachable_user(user, exc, 'уведомление о завершении тестовой подписки'):
|
||||
return True
|
||||
logger.error(
|
||||
'Ошибка Telegram API при отправке уведомления о завершении тестовой подписки пользователю',
|
||||
@@ -1301,16 +1366,16 @@ class MonitoringService:
|
||||
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
|
||||
unsubscribed = await channel_subscription_service.get_unsubscribed_channels(user.telegram_id)
|
||||
|
||||
buttons = []
|
||||
if settings.CHANNEL_LINK:
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
|
||||
url=settings.CHANNEL_LINK,
|
||||
)
|
||||
]
|
||||
)
|
||||
for ch in unsubscribed:
|
||||
link = ch.get('channel_link')
|
||||
if link:
|
||||
title = ch.get('title') or texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться')
|
||||
buttons.append([InlineKeyboardButton(text=f'🔗 {title}', url=link)])
|
||||
buttons.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
@@ -1327,11 +1392,12 @@ class MonitoringService:
|
||||
text=message,
|
||||
parse_mode='HTML',
|
||||
reply_markup=keyboard,
|
||||
user=user,
|
||||
)
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, 'уведомление об отписке от канала'):
|
||||
if await self._handle_unreachable_user(user, exc, 'уведомление об отписке от канала'):
|
||||
return True
|
||||
logger.error(
|
||||
'Ошибка Telegram API при отправке уведомления об отписке от канала пользователю',
|
||||
@@ -1402,7 +1468,7 @@ class MonitoringService:
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, 'напоминание об истекшей подписке'):
|
||||
if await self._handle_unreachable_user(user, exc, 'напоминание об истекшей подписке'):
|
||||
return True
|
||||
logger.error(
|
||||
'Ошибка Telegram API при отправке напоминания об истекшей подписке пользователю',
|
||||
@@ -1497,7 +1563,7 @@ class MonitoringService:
|
||||
return True
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if self._handle_unreachable_user(user, exc, 'скидочное уведомление'):
|
||||
if await self._handle_unreachable_user(user, exc, 'скидочное уведомление'):
|
||||
return True
|
||||
logger.error(
|
||||
'Ошибка Telegram API при отправке скидочного уведомления пользователю',
|
||||
@@ -1522,7 +1588,7 @@ class MonitoringService:
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if not self._handle_unreachable_user(user, exc, 'уведомление об успешном автоплатеже'):
|
||||
if not await self._handle_unreachable_user(user, exc, 'уведомление об успешном автоплатеже'):
|
||||
logger.error(
|
||||
'Ошибка Telegram API при отправке уведомления об автоплатеже пользователю',
|
||||
telegram_id=user.telegram_id,
|
||||
@@ -1559,7 +1625,7 @@ class MonitoringService:
|
||||
)
|
||||
|
||||
except (TelegramForbiddenError, TelegramBadRequest) as exc:
|
||||
if not self._handle_unreachable_user(user, exc, 'уведомление о неудачном автоплатеже'):
|
||||
if not await self._handle_unreachable_user(user, exc, 'уведомление о неудачном автоплатеже'):
|
||||
logger.error(
|
||||
'Ошибка Telegram API при отправке уведомления о неудачном автоплатеже пользователю',
|
||||
telegram_id=user.telegram_id,
|
||||
|
||||
@@ -61,7 +61,10 @@ class PaymentCommonMixin:
|
||||
)
|
||||
row = result.one_or_none()
|
||||
if row:
|
||||
is_active = row.status == 'active' and row.end_date > datetime.now(UTC)
|
||||
end_date = row.end_date
|
||||
if end_date is not None and end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=UTC)
|
||||
is_active = row.status == 'active' and end_date is not None and end_date > datetime.now(UTC)
|
||||
has_active_subscription = bool(is_active and not row.is_trial)
|
||||
except Exception as db_error:
|
||||
logger.warning(
|
||||
|
||||
@@ -32,6 +32,8 @@ class FreekassaPaymentMixin:
|
||||
description: str = 'Пополнение баланса',
|
||||
email: str | None = None,
|
||||
language: str = 'ru',
|
||||
payment_system_id: int | None = None,
|
||||
payment_method: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Создает платеж Freekassa.
|
||||
@@ -83,18 +85,23 @@ class FreekassaPaymentMixin:
|
||||
'description': description,
|
||||
'language': language,
|
||||
'type': 'balance_topup',
|
||||
'payment_method': payment_method or 'freekassa',
|
||||
}
|
||||
|
||||
try:
|
||||
# Определяем payment_system_id: явно переданный > из настроек
|
||||
ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID
|
||||
|
||||
# Выбираем способ создания платежа: API или форма
|
||||
if settings.FREEKASSA_USE_API:
|
||||
# Используем API для создания заказа (нужно для NSPK СБП)
|
||||
# Если указан payment_system_id — всегда используем API
|
||||
if settings.FREEKASSA_USE_API or ps_id:
|
||||
# Используем API для создания заказа
|
||||
payment_url = await freekassa_service.create_order_and_get_url(
|
||||
order_id=order_id,
|
||||
amount=amount_rubles,
|
||||
currency=currency,
|
||||
email=email,
|
||||
payment_system_id=settings.FREEKASSA_PAYMENT_SYSTEM_ID,
|
||||
payment_system_id=ps_id,
|
||||
)
|
||||
logger.info('Freekassa API: создан заказ order_id url', order_id=order_id, payment_url=payment_url)
|
||||
else:
|
||||
@@ -339,7 +346,18 @@ class FreekassaPaymentMixin:
|
||||
if getattr(self, 'bot', None) and user.telegram_id:
|
||||
try:
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
display_name = settings.get_freekassa_display_name()
|
||||
|
||||
# Resolve display name from payment metadata (sub-method aware)
|
||||
display_name = settings.get_freekassa_display_name_html()
|
||||
try:
|
||||
meta = json.loads(payment.metadata_json) if payment.metadata_json else {}
|
||||
pm = meta.get('payment_method', 'freekassa')
|
||||
if pm == 'freekassa_sbp':
|
||||
display_name = settings.get_freekassa_sbp_display_name_html()
|
||||
elif pm == 'freekassa_card':
|
||||
display_name = settings.get_freekassa_card_display_name_html()
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
(
|
||||
|
||||
@@ -258,7 +258,7 @@ class YooKassaPaymentMixin:
|
||||
status=yookassa_response['status'],
|
||||
confirmation_url=yookassa_response.get('confirmation_url'), # Используем confirmation URL
|
||||
metadata_json=payment_metadata,
|
||||
payment_method_type='bank_card',
|
||||
payment_method_type='sbp',
|
||||
yookassa_created_at=None,
|
||||
test_mode=yookassa_response.get('test_mode', False),
|
||||
)
|
||||
@@ -897,6 +897,7 @@ class YooKassaPaymentMixin:
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}',
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
logger.info(
|
||||
'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю',
|
||||
@@ -1079,14 +1080,14 @@ class YooKassaPaymentMixin:
|
||||
'Успешно обработан платеж YooKassa как покупка подписки: пользователь , сумма ₽',
|
||||
yookassa_payment_id=payment.yookassa_payment_id,
|
||||
user_id=payment.user_id,
|
||||
amount_kopeks=payment.amount_kopeks / 100,
|
||||
amount_rubles=payment.amount_kopeks / 100,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
'Успешно обработан платеж YooKassa : пользователь пополнил баланс на ₽',
|
||||
yookassa_payment_id=payment.yookassa_payment_id,
|
||||
user_id=payment.user_id,
|
||||
amount_kopeks=payment.amount_kopeks / 100,
|
||||
amount_rubles=payment.amount_kopeks / 100,
|
||||
)
|
||||
|
||||
# Создаем чек через NaloGO (если NALOGO_ENABLED=true)
|
||||
|
||||
@@ -98,6 +98,20 @@ def _get_method_defaults() -> dict:
|
||||
{'id': 'card', 'name': 'Карта'},
|
||||
],
|
||||
},
|
||||
'freekassa_sbp': {
|
||||
'default_display_name': settings.get_freekassa_sbp_display_name(),
|
||||
'is_configured': settings.is_freekassa_sbp_enabled(),
|
||||
'default_min': settings.FREEKASSA_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.FREEKASSA_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
'freekassa_card': {
|
||||
'default_display_name': settings.get_freekassa_card_display_name(),
|
||||
'is_configured': settings.is_freekassa_card_enabled(),
|
||||
'default_min': settings.FREEKASSA_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.FREEKASSA_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
'cloudpayments': {
|
||||
'default_display_name': settings.get_cloudpayments_display_name(),
|
||||
'is_configured': settings.is_cloudpayments_enabled(),
|
||||
@@ -151,6 +165,8 @@ DEFAULT_METHOD_ORDER = [
|
||||
'platega',
|
||||
'wata',
|
||||
'freekassa',
|
||||
'freekassa_sbp',
|
||||
'freekassa_card',
|
||||
'cloudpayments',
|
||||
'kassa_ai',
|
||||
]
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Permission Engine — RBAC + ABAC evaluation for admin cabinet.
|
||||
|
||||
Combines role-based permission checks (fnmatch wildcards) with
|
||||
attribute-based access policies (time ranges, IP whitelists).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from datetime import UTC, datetime
|
||||
from fnmatch import fnmatch
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.rbac import AccessPolicyCRUD, AuditLogCRUD, UserRoleCRUD
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.database.models import AccessPolicy, User
|
||||
|
||||
|
||||
SUPERADMIN_LEVEL = 999
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _is_legacy_admin(user: User) -> bool:
|
||||
"""Check if user is a legacy config-based admin (ADMIN_IDS / ADMIN_EMAILS)."""
|
||||
return settings.is_admin(
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email if user.email_verified else None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permission Registry — section -> available actions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PERMISSION_REGISTRY: dict[str, list[str]] = {
|
||||
'users': [
|
||||
'read',
|
||||
'edit',
|
||||
'block',
|
||||
'delete',
|
||||
'sync',
|
||||
'promo_group',
|
||||
'balance',
|
||||
'subscription',
|
||||
'send_offer',
|
||||
'referral',
|
||||
],
|
||||
'tickets': ['read', 'reply', 'close', 'settings'],
|
||||
'stats': ['read', 'export'],
|
||||
'broadcasts': ['read', 'create', 'edit', 'delete', 'send'],
|
||||
'tariffs': ['read', 'create', 'edit', 'delete'],
|
||||
'promocodes': ['read', 'create', 'edit', 'delete', 'stats'],
|
||||
'promo_groups': ['read', 'create', 'edit', 'delete'],
|
||||
'promo_offers': ['read', 'create', 'edit', 'send'],
|
||||
'campaigns': ['read', 'create', 'edit', 'delete', 'stats'],
|
||||
'partners': ['read', 'edit', 'approve', 'revoke', 'settings'],
|
||||
'withdrawals': ['read', 'approve', 'reject'],
|
||||
'payments': ['read', 'edit', 'export'],
|
||||
'payment_methods': ['read', 'edit'],
|
||||
'servers': ['read', 'edit'],
|
||||
'remnawave': ['read', 'sync', 'manage'],
|
||||
'traffic': ['read', 'export'],
|
||||
'settings': ['read', 'edit'],
|
||||
'roles': ['read', 'create', 'edit', 'delete', 'assign'],
|
||||
'audit_log': ['read', 'export'],
|
||||
'channels': ['read', 'edit'],
|
||||
'ban_system': ['read', 'edit', 'ban', 'unban'],
|
||||
'wheel': ['read', 'edit'],
|
||||
'apps': ['read', 'edit'],
|
||||
'email_templates': ['read', 'edit'],
|
||||
'pinned_messages': ['read', 'create', 'edit', 'delete'],
|
||||
'updates': ['read', 'manage'],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_all_permissions() -> list[str]:
|
||||
"""Return flat list of all permissions: ``['users:read', 'users:edit', ...]``."""
|
||||
return [f'{section}:{action}' for section, actions in PERMISSION_REGISTRY.items() for action in actions]
|
||||
|
||||
|
||||
def permission_matches(user_perm: str, required_perm: str) -> bool:
|
||||
"""Check if *user_perm* grants access for *required_perm*.
|
||||
|
||||
Wildcard rules (fnmatch):
|
||||
- ``*:*`` matches everything
|
||||
- ``users:*`` matches ``users:read``, ``users:edit``, ...
|
||||
- ``users:read`` matches only ``users:read``
|
||||
"""
|
||||
return fnmatch(required_perm, user_perm)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal ABAC helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _policy_matches_resource(policy: AccessPolicy, required_perm: str) -> bool:
|
||||
"""Check if an ABAC policy applies to the requested permission.
|
||||
|
||||
``policy.resource`` is the section pattern (e.g. ``users`` or ``*``).
|
||||
``policy.actions`` is a list of action patterns (e.g. ``['read', '*']``).
|
||||
"""
|
||||
if ':' not in required_perm:
|
||||
return False
|
||||
|
||||
section, action = required_perm.split(':', maxsplit=1)
|
||||
|
||||
if not fnmatch(section, policy.resource):
|
||||
return False
|
||||
|
||||
policy_actions: list[str] = policy.actions or []
|
||||
return any(fnmatch(action, pattern) for pattern in policy_actions)
|
||||
|
||||
|
||||
def _evaluate_conditions(
|
||||
conditions: dict | None,
|
||||
*,
|
||||
ip_address: str | None = None,
|
||||
) -> bool:
|
||||
"""Evaluate ABAC conditions dict. Returns ``True`` when ALL conditions are met.
|
||||
|
||||
Supported keys:
|
||||
- ``time_range``: ``{"start": "09:00", "end": "18:00"}`` -- current UTC time
|
||||
must fall within the range (inclusive start, exclusive end).
|
||||
- ``ip_whitelist``: ``["192.168.1.0/24", "10.0.0.1"]`` -- *ip_address* must
|
||||
match at least one entry (CIDR network or exact host).
|
||||
- ``max_actions_per_hour``: reserved for future rate-limit logic; always passes.
|
||||
"""
|
||||
if not conditions:
|
||||
return True
|
||||
|
||||
# --- time_range ---
|
||||
time_range = conditions.get('time_range')
|
||||
if time_range is not None:
|
||||
now = datetime.now(UTC).time()
|
||||
try:
|
||||
start = datetime.strptime(time_range['start'], '%H:%M').time()
|
||||
end = datetime.strptime(time_range['end'], '%H:%M').time()
|
||||
except (KeyError, ValueError) as exc:
|
||||
logger.warning('Invalid time_range condition', condition=time_range, error=str(exc))
|
||||
return False
|
||||
|
||||
if start <= end:
|
||||
# Normal range, e.g. 09:00..18:00
|
||||
if not (start <= now < end):
|
||||
return False
|
||||
# Overnight range, e.g. 22:00..06:00
|
||||
elif not (now >= start or now < end):
|
||||
return False
|
||||
|
||||
# --- ip_whitelist ---
|
||||
ip_whitelist: list[str] | None = conditions.get('ip_whitelist')
|
||||
if ip_whitelist is not None:
|
||||
if ip_address is None:
|
||||
# No IP provided but whitelist required -- deny
|
||||
return False
|
||||
|
||||
try:
|
||||
client_ip = ipaddress.ip_address(ip_address)
|
||||
except ValueError:
|
||||
logger.warning('Invalid client IP address', ip_address=ip_address)
|
||||
return False
|
||||
|
||||
matched = False
|
||||
for entry in ip_whitelist:
|
||||
try:
|
||||
network = ipaddress.ip_network(entry, strict=False)
|
||||
if client_ip in network:
|
||||
matched = True
|
||||
break
|
||||
except ValueError:
|
||||
logger.warning('Invalid IP whitelist entry', entry=entry)
|
||||
continue
|
||||
|
||||
if not matched:
|
||||
return False
|
||||
|
||||
# --- max_actions_per_hour (stub) ---
|
||||
# Will be implemented with rate-limit counters later.
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PermissionService:
|
||||
"""Stateless permission engine combining RBAC + ABAC evaluation."""
|
||||
|
||||
@staticmethod
|
||||
async def check_permission(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
required_permission: str,
|
||||
*,
|
||||
ip_address: str | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Evaluate whether *user* may perform *required_permission*.
|
||||
|
||||
Returns ``(allowed, reason)`` tuple.
|
||||
|
||||
Algorithm:
|
||||
1. Aggregate user permissions via ``UserRoleCRUD.get_user_permissions``.
|
||||
2. Check if any RBAC permission matches the required one (fnmatch).
|
||||
3. If base RBAC permission is **not** granted -- deny immediately.
|
||||
4. Fetch ABAC policies applicable to the user's roles.
|
||||
5. Evaluate matching policies in priority order; **deny wins over allow**
|
||||
at the same priority level.
|
||||
"""
|
||||
# Step 0 -- legacy config-based admins get full access
|
||||
if _is_legacy_admin(user):
|
||||
return True, 'Granted by legacy admin config'
|
||||
|
||||
# Step 1 -- aggregate RBAC permissions
|
||||
permissions, role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user.id)
|
||||
|
||||
if not permissions:
|
||||
logger.debug(
|
||||
'Permission denied: no active roles',
|
||||
user_id=user.id,
|
||||
required=required_permission,
|
||||
)
|
||||
return False, 'No active roles assigned'
|
||||
|
||||
# Step 2 -- RBAC wildcard matching
|
||||
rbac_granted = any(permission_matches(perm, required_permission) for perm in permissions)
|
||||
|
||||
if not rbac_granted:
|
||||
logger.debug(
|
||||
'Permission denied: RBAC mismatch',
|
||||
user_id=user.id,
|
||||
required=required_permission,
|
||||
permissions=permissions,
|
||||
)
|
||||
return False, 'Permission not granted by any role'
|
||||
|
||||
# Step 3 -- load ABAC policies for the user's roles
|
||||
user_roles = await UserRoleCRUD.get_user_roles(db, user.id)
|
||||
role_ids = [ur.role_id for ur in user_roles]
|
||||
policies = await AccessPolicyCRUD.get_policies_for_user(db, role_ids)
|
||||
|
||||
if not policies:
|
||||
# No ABAC policies -- RBAC alone grants access
|
||||
return True, 'Granted by RBAC'
|
||||
|
||||
# Step 4 -- evaluate ABAC policies (highest priority first, already sorted)
|
||||
explicit_deny = False
|
||||
deny_reason = ''
|
||||
|
||||
for policy in policies:
|
||||
if not _policy_matches_resource(policy, required_permission):
|
||||
continue
|
||||
|
||||
conditions_met = _evaluate_conditions(
|
||||
policy.conditions,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
if not conditions_met:
|
||||
# Conditions not satisfied -- this policy does not apply
|
||||
continue
|
||||
|
||||
if policy.effect == 'deny':
|
||||
explicit_deny = True
|
||||
deny_reason = f'Denied by policy: {policy.name}'
|
||||
logger.debug(
|
||||
'Permission denied by ABAC policy',
|
||||
user_id=user.id,
|
||||
required=required_permission,
|
||||
policy_id=policy.id,
|
||||
policy_name=policy.name,
|
||||
)
|
||||
# Deny is final -- stop evaluation
|
||||
break
|
||||
|
||||
# effect == 'allow' does not override a prior deny at higher priority,
|
||||
# but since policies are sorted desc and deny breaks immediately,
|
||||
# reaching here means no deny has fired yet -- just continue.
|
||||
|
||||
if explicit_deny:
|
||||
return False, deny_reason
|
||||
|
||||
return True, 'Granted by RBAC + ABAC'
|
||||
|
||||
@staticmethod
|
||||
async def get_user_permissions(db: AsyncSession, user_id: int, user: User | None = None) -> dict:
|
||||
"""Return aggregated permission info for a user.
|
||||
|
||||
Returns::
|
||||
|
||||
{
|
||||
'permissions': ['users:read', ...],
|
||||
'roles': ['editor', 'moderator'],
|
||||
'role_level': 50,
|
||||
}
|
||||
"""
|
||||
permissions, role_names, max_level = await UserRoleCRUD.get_user_permissions(db, user_id)
|
||||
|
||||
# Legacy config-based admins get full superadmin permissions
|
||||
# Level is SUPERADMIN_LEVEL + 1 so they can manage all roles including level-999
|
||||
if user is not None and not permissions and _is_legacy_admin(user):
|
||||
permissions = ['*:*']
|
||||
role_names = ['superadmin']
|
||||
max_level = SUPERADMIN_LEVEL + 1
|
||||
|
||||
return {
|
||||
'permissions': permissions,
|
||||
'roles': role_names,
|
||||
'role_level': max_level,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def log_action(
|
||||
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,
|
||||
) -> None:
|
||||
"""Persist an admin audit log entry via ``AuditLogCRUD``."""
|
||||
await AuditLogCRUD.create(
|
||||
db,
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
RBAC bootstrap service.
|
||||
|
||||
Auto-assigns the Superadmin role to users listed in ADMIN_IDS / ADMIN_EMAILS
|
||||
config on bot startup. Runs once during the startup sequence.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Final
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import AdminRole, User, UserRole
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
SUPERADMIN_ROLE_NAME: Final[str] = 'Superadmin'
|
||||
|
||||
# Preset roles seeded on first run
|
||||
_PRESET_ROLES: list[dict] = [
|
||||
{
|
||||
'name': 'Superadmin',
|
||||
'description': 'Full system access',
|
||||
'level': 999,
|
||||
'permissions': ['*:*'],
|
||||
'color': '#EF4444',
|
||||
'icon': 'shield',
|
||||
'is_system': True,
|
||||
},
|
||||
{
|
||||
'name': 'Admin',
|
||||
'description': 'Administrative access',
|
||||
'level': 100,
|
||||
'permissions': [
|
||||
'users:*',
|
||||
'tickets:*',
|
||||
'stats:*',
|
||||
'broadcasts:*',
|
||||
'tariffs:*',
|
||||
'promocodes:*',
|
||||
'promo_groups:*',
|
||||
'promo_offers:*',
|
||||
'campaigns:*',
|
||||
'partners:*',
|
||||
'withdrawals:*',
|
||||
'payments:*',
|
||||
'payment_methods:*',
|
||||
'servers:*',
|
||||
'remnawave:*',
|
||||
'traffic:*',
|
||||
'settings:*',
|
||||
'roles:read',
|
||||
'roles:create',
|
||||
'roles:edit',
|
||||
'roles:assign',
|
||||
'audit_log:*',
|
||||
'channels:*',
|
||||
'ban_system:*',
|
||||
'wheel:*',
|
||||
'apps:*',
|
||||
'email_templates:*',
|
||||
'pinned_messages:*',
|
||||
'updates:*',
|
||||
],
|
||||
'color': '#F59E0B',
|
||||
'icon': 'crown',
|
||||
'is_system': True,
|
||||
},
|
||||
{
|
||||
'name': 'Moderator',
|
||||
'description': 'User and ticket management',
|
||||
'level': 50,
|
||||
'permissions': ['users:read', 'users:edit', 'users:block', 'tickets:*', 'ban_system:*'],
|
||||
'color': '#3B82F6',
|
||||
'icon': 'user-shield',
|
||||
'is_system': True,
|
||||
},
|
||||
{
|
||||
'name': 'Marketer',
|
||||
'description': 'Marketing tools access',
|
||||
'level': 30,
|
||||
'permissions': [
|
||||
'campaigns:*',
|
||||
'broadcasts:*',
|
||||
'promocodes:*',
|
||||
'promo_offers:*',
|
||||
'promo_groups:*',
|
||||
'stats:read',
|
||||
'pinned_messages:*',
|
||||
'wheel:*',
|
||||
],
|
||||
'color': '#8B5CF6',
|
||||
'icon': 'megaphone',
|
||||
'is_system': True,
|
||||
},
|
||||
{
|
||||
'name': 'Support',
|
||||
'description': 'Ticket support access',
|
||||
'level': 20,
|
||||
'permissions': ['tickets:read', 'tickets:reply', 'users:read'],
|
||||
'color': '#10B981',
|
||||
'icon': 'headset',
|
||||
'is_system': True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def _ensure_preset_roles(db: AsyncSession) -> AdminRole | None:
|
||||
"""Seed preset roles if they don't exist. Returns the Superadmin role."""
|
||||
superadmin_role: AdminRole | None = None
|
||||
|
||||
for preset in _PRESET_ROLES:
|
||||
result = await db.execute(select(AdminRole).where(AdminRole.name == preset['name']))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
if preset['name'] == SUPERADMIN_ROLE_NAME:
|
||||
superadmin_role = existing
|
||||
continue
|
||||
|
||||
role = AdminRole(
|
||||
name=preset['name'],
|
||||
description=preset['description'],
|
||||
level=preset['level'],
|
||||
permissions=preset['permissions'],
|
||||
color=preset['color'],
|
||||
icon=preset['icon'],
|
||||
is_system=preset['is_system'],
|
||||
is_active=True,
|
||||
)
|
||||
db.add(role)
|
||||
await db.flush()
|
||||
logger.info('Seeded preset role', role_name=preset['name'], role_id=role.id)
|
||||
|
||||
if preset['name'] == SUPERADMIN_ROLE_NAME:
|
||||
superadmin_role = role
|
||||
|
||||
return superadmin_role
|
||||
|
||||
|
||||
async def bootstrap_superadmins(db: AsyncSession) -> None:
|
||||
"""Ensure every user from ADMIN_IDS / ADMIN_EMAILS has the Superadmin role.
|
||||
|
||||
Also seeds preset roles on first run.
|
||||
Idempotent: skips users who already hold an active Superadmin assignment.
|
||||
Commits only when at least one change was made.
|
||||
"""
|
||||
try:
|
||||
admin_ids = settings.get_admin_ids()
|
||||
admin_emails = settings.get_admin_emails()
|
||||
|
||||
# ── 1. Ensure preset roles exist (seeds on first run) ──────────
|
||||
superadmin_role = await _ensure_preset_roles(db)
|
||||
|
||||
if superadmin_role is None:
|
||||
logger.error('Failed to resolve Superadmin role after seeding')
|
||||
return
|
||||
|
||||
if not admin_ids and not admin_emails:
|
||||
logger.debug('No admin IDs or emails configured, skipping superadmin assignment')
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
role_id: int = superadmin_role.id
|
||||
assigned_count = 0
|
||||
|
||||
# ── 2. Process admin telegram IDs ──────────────────────────────
|
||||
for telegram_id in admin_ids:
|
||||
assigned = await _ensure_role_by_telegram_id(db, telegram_id=telegram_id, role_id=role_id)
|
||||
if assigned:
|
||||
assigned_count += 1
|
||||
|
||||
# ── 3. Process admin emails ────────────────────────────────────
|
||||
for email in admin_emails:
|
||||
assigned = await _ensure_role_by_email(db, email=email, role_id=role_id)
|
||||
if assigned:
|
||||
assigned_count += 1
|
||||
|
||||
# ── 4. Commit all changes ──────────────────────────────────────
|
||||
await db.commit()
|
||||
|
||||
if assigned_count > 0:
|
||||
logger.info(
|
||||
'Superadmin bootstrap completed',
|
||||
assigned_count=assigned_count,
|
||||
role_id=role_id,
|
||||
)
|
||||
else:
|
||||
logger.debug('Superadmin bootstrap: no new assignments needed')
|
||||
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
logger.exception('Failed to bootstrap superadmins, continuing startup')
|
||||
|
||||
|
||||
async def _ensure_role_by_telegram_id(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
telegram_id: int,
|
||||
role_id: int,
|
||||
) -> bool:
|
||||
"""Assign Superadmin role to user found by telegram_id. Returns True if assigned."""
|
||||
result = await db.execute(select(User).where(User.telegram_id == telegram_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
logger.debug(
|
||||
'Admin user not yet registered, skipping',
|
||||
telegram_id=telegram_id,
|
||||
)
|
||||
return False
|
||||
|
||||
return await _assign_if_missing(db, user_id=user.id, role_id=role_id, identifier=str(telegram_id))
|
||||
|
||||
|
||||
async def _ensure_role_by_email(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
email: str,
|
||||
role_id: int,
|
||||
) -> bool:
|
||||
"""Assign Superadmin role to user found by email (case-insensitive). Returns True if assigned."""
|
||||
result = await db.execute(select(User).where(func.lower(User.email) == email.lower()))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
logger.debug(
|
||||
'Admin user (email) not yet registered, skipping',
|
||||
email=email,
|
||||
)
|
||||
return False
|
||||
|
||||
return await _assign_if_missing(db, user_id=user.id, role_id=role_id, identifier=email)
|
||||
|
||||
|
||||
async def _assign_if_missing(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
role_id: int,
|
||||
identifier: str,
|
||||
) -> bool:
|
||||
"""Create or reactivate a UserRole row for this user/role pair.
|
||||
|
||||
Handles the unique constraint on (user_id, role_id) by checking for
|
||||
ANY existing assignment (active or inactive) and reactivating if needed.
|
||||
|
||||
Returns True if a new assignment was created or an inactive one was reactivated.
|
||||
"""
|
||||
# Check for ANY existing assignment (active or not) to respect 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:
|
||||
if existing.is_active:
|
||||
logger.debug(
|
||||
'User already has Superadmin role',
|
||||
user_id=user_id,
|
||||
identifier=identifier,
|
||||
)
|
||||
return False
|
||||
# Reactivate previously revoked assignment
|
||||
existing.is_active = True
|
||||
existing.assigned_at = datetime.now(UTC)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
'Reactivated Superadmin role for user',
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
identifier=identifier,
|
||||
user_role_id=existing.id,
|
||||
)
|
||||
return True
|
||||
|
||||
user_role = UserRole(
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user_role)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
'Assigned Superadmin role to user',
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
identifier=identifier,
|
||||
user_role_id=user_role.id,
|
||||
)
|
||||
return True
|
||||
@@ -323,14 +323,11 @@ class ReferralContestService:
|
||||
if not self.bot:
|
||||
return
|
||||
|
||||
channel_id_raw = settings.CHANNEL_SUB_ID
|
||||
if not channel_id_raw:
|
||||
return
|
||||
from app.services.channel_subscription_service import channel_subscription_service
|
||||
|
||||
try:
|
||||
channel_id = int(channel_id_raw)
|
||||
except Exception:
|
||||
channel_id = channel_id_raw
|
||||
channel_id = await channel_subscription_service.get_first_channel_id()
|
||||
if not channel_id:
|
||||
return
|
||||
|
||||
lines = [
|
||||
f'🏆 {contest.title}',
|
||||
@@ -358,9 +355,9 @@ class ReferralContestService:
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except (TelegramForbiddenError, TelegramNotFound):
|
||||
logger.info('Не удалось отправить сводку конкурса в канал', channel_id_raw=channel_id_raw)
|
||||
logger.info('Не удалось отправить сводку конкурса в канал', channel_id=channel_id)
|
||||
except Exception as exc:
|
||||
logger.error('Ошибка отправки сводки конкурса в канал', channel_id_raw=channel_id_raw, exc=exc)
|
||||
logger.error('Ошибка отправки сводки конкурса в канал', channel_id=channel_id, exc=exc)
|
||||
|
||||
def _build_participant_message(
|
||||
self,
|
||||
|
||||
@@ -61,6 +61,10 @@ async def send_referral_notification(
|
||||
|
||||
async def process_referral_registration(db: AsyncSession, new_user_id: int, referrer_id: int, bot: Bot = None):
|
||||
try:
|
||||
if new_user_id == referrer_id:
|
||||
logger.warning('Self-referral blocked in process_referral_registration', user_id=new_user_id)
|
||||
return False
|
||||
|
||||
new_user = await get_user_by_id(db, new_user_id)
|
||||
referrer = await get_user_by_id(db, referrer_id)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user