Compare commits

..

444 Commits

Author SHA1 Message Date
Egor 29743351de Delete app/webapi/vpn-admin-dashboard.html 2025-09-30 03:52:57 +03:00
Egor fd897464f5 Update Dockerfile 2025-09-30 03:52:36 +03:00
Egor d9b0420202 Update docker-registry.yml 2025-09-30 03:52:24 +03:00
Egor 527d907035 Update docker-hub.yml 2025-09-30 03:52:11 +03:00
Egor 6c3270e6d7 Merge pull request #590 from Fr1ngg/dev2
Dev to main
2025-09-30 03:36:11 +03:00
Egor b7382eb7c3 Merge pull request #589 from Fr1ngg/bedolaga/update-bot-configuration-menu
Update admin bot configuration menu grouping
2025-09-30 03:32:04 +03:00
Egor 0cd7d45338 Refine bot configuration menu structure 2025-09-30 03:31:37 +03:00
Egor 23dcb9699c Merge pull request #588 from Fr1ngg/bedolaga/fix-duplicate-cryptolink-display
Remove duplicate Happ CryptoLink link display
2025-09-30 03:17:49 +03:00
Egor 9603952b2a Adjust Happ CryptoLink message formatting 2025-09-30 03:17:17 +03:00
Egor 299eeee9d3 Merge pull request #587 from Fr1ngg/bedolaga-xjizv4
Convert Happ cryptolink message to happ scheme
2025-09-30 03:11:48 +03:00
Egor 63c7e9c08b Adjust Happ cryptolink message formatting 2025-09-30 03:11:28 +03:00
Egor 42131457bf Merge pull request #586 from Fr1ngg/revert-585-bedolaga-uswi1l
Revert "feat: update Happ cryptolink subscription link handling"
2025-09-30 03:11:17 +03:00
Egor a7370fbe52 Revert "feat: update Happ cryptolink subscription link handling" 2025-09-30 03:11:08 +03:00
Egor 41f0a15cea Merge pull request #585 from Fr1ngg/bedolaga-uswi1l
feat: update Happ cryptolink subscription link handling
2025-09-30 03:08:59 +03:00
Egor dd92f37fc1 feat: adjust Happ cryptolink presentation 2025-09-30 03:08:42 +03:00
Egor 46d4d699dd Merge pull request #584 from Fr1ngg/revert-583-bedolaga-bsbzht
Revert "Update Happ CryptoLink message formatting"
2025-09-30 03:07:37 +03:00
Egor 76b594363b Revert "Update Happ CryptoLink message formatting" 2025-09-30 03:07:28 +03:00
Egor 107b18d642 Merge pull request #583 from Fr1ngg/bedolaga-bsbzht
Update Happ CryptoLink message formatting
2025-09-30 03:05:01 +03:00
Egor 9973491503 Update Happ CryptoLink message formatting 2025-09-30 03:04:26 +03:00
Egor 418081048e Merge pull request #582 from Fr1ngg/bedolaga/add-language-selection-toggle-in-bot-config
feat: allow disabling language selection
2025-09-30 02:51:53 +03:00
Egor f22838b99f feat: make language selection optional 2025-09-30 02:51:31 +03:00
Egor 9bdacea568 Update .env.example 2025-09-30 02:50:26 +03:00
Egor 6bec0ed0c8 Merge pull request #581 from Fr1ngg/bedolaga/add-language-selection-at-start
Add language selection workflow and menu option
2025-09-30 02:38:29 +03:00
Egor 4363955443 Add language selection workflow and menu option 2025-09-30 02:37:57 +03:00
Egor 47a272c7e8 Merge pull request #580 from Fr1ngg/bedolaga/fix-subscription-renewal-error
Prevent async session errors when extending subscriptions
2025-09-30 02:17:24 +03:00
Egor 07ce852917 Fix subscription extension notifications and bot init 2025-09-30 02:17:06 +03:00
Egor a6ada5c9e3 Merge pull request #579 from Fr1ngg/bedolaga/add-admin-notifications-for-promo-groups-zboyk5
feat: expand admin promo notifications
2025-09-30 02:06:47 +03:00
Egor 90c5b1d003 feat: expand admin notifications for promo events 2025-09-30 02:06:31 +03:00
Egor 326aca31d7 Merge pull request #578 from Fr1ngg/revert-577-bedolaga/add-admin-notifications-for-promo-groups
Revert "Enhance admin notifications with promo group context"
2025-09-30 02:06:20 +03:00
Egor 4fd5ba863b Revert "Enhance admin notifications with promo group context" 2025-09-30 02:06:11 +03:00
Egor 4bd0b221e7 Merge pull request #577 from Fr1ngg/bedolaga/add-admin-notifications-for-promo-groups
Enhance admin notifications with promo group context
2025-09-30 01:57:14 +03:00
Egor f47511f26a Enhance admin notifications with promo group context 2025-09-30 01:56:52 +03:00
Egor 2d409eaa1f Merge pull request #576 from Fr1ngg/bedolaga/update-subscription-status-to-icons
Move subscription status icons into server user buttons
2025-09-30 01:41:16 +03:00
Egor 9d42c4fb7d Adjust server user list status icons 2025-09-30 01:41:03 +03:00
Egor d3ced8f7e2 Merge pull request #575 from Fr1ngg/bedolaga/add-pagination-to-users-list-page
Add pagination to admin server users list
2025-09-30 01:34:24 +03:00
Egor a5715b2ca8 Add pagination to server users admin view 2025-09-30 01:34:07 +03:00
Egor c18dd604d8 Merge pull request #574 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-g9vyec
Fix server user list to account for JSON-based connections
2025-09-30 01:28:59 +03:00
Egor c2656f4ee6 Fix server users list by including JSON connections 2025-09-30 01:28:43 +03:00
Egor 18641d46d3 Merge pull request #573 from Fr1ngg/revert-572-bedolaga/fix-user-count-display-on-server-list-e72yq9
Revert "Fix server user list counts"
2025-09-30 01:28:28 +03:00
Egor 7ef3a08215 Revert "Fix server user list counts" 2025-09-30 01:28:20 +03:00
Egor 55f1a3b220 Merge pull request #572 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-e72yq9
Fix server user list counts
2025-09-30 01:26:51 +03:00
Egor e2f313e6ea Fix server user list counts 2025-09-30 01:26:37 +03:00
Egor 5f99478433 Merge pull request #571 from Fr1ngg/revert-570-bedolaga/fix-user-count-display-on-server-list-gt974l
Revert "Fix server user list to include JSON connections"
2025-09-30 01:26:12 +03:00
Egor 89f0a3663d Revert "Fix server user list to include JSON connections" 2025-09-30 01:26:03 +03:00
Egor e53dab87e3 Merge pull request #570 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list-gt974l
Fix server user list to include JSON connections
2025-09-30 01:24:22 +03:00
Egor 6d85c179bb Fix server user list to include JSON connections 2025-09-30 01:24:05 +03:00
Egor 30dafcef49 Merge pull request #569 from Fr1ngg/revert-568-bedolaga/fix-user-count-display-on-server-list
Revert "Fix admin server user list query"
2025-09-30 01:23:47 +03:00
Egor 32d7a7394e Revert "Fix admin server user list query" 2025-09-30 01:23:36 +03:00
Egor 3cb4f27433 Merge pull request #568 from Fr1ngg/bedolaga/fix-user-count-display-on-server-list
Fix admin server user list query
2025-09-30 01:22:06 +03:00
Egor 50692566d5 Fix server user list to use connected squads 2025-09-30 01:21:49 +03:00
Egor fbb2afd9e1 Merge pull request #567 from Fr1ngg/bedolaga/update-backup-and-restore-mechanism
Expand backup coverage to include configuration snapshots
2025-09-30 01:03:15 +03:00
Egor 3de951b153 Enhance backup coverage and configuration snapshots 2025-09-30 01:02:57 +03:00
Egor 1b86d467b5 Merge pull request #566 from Fr1ngg/bedolaga/investigate-subscription-cancellation-bug
Convert extended trials to paid subscriptions
2025-09-30 00:51:15 +03:00
Egor e4743ced9b Convert extended trials to paid subscriptions 2025-09-30 00:50:57 +03:00
Egor 2351056d56 Merge pull request #565 from Fr1ngg/bedolaga/fix-subscription-promotion-allocation
Fix total spent calculation for promo groups
2025-09-30 00:43:06 +03:00
Egor 748f9a2393 Fix total spent calculation for promo groups 2025-09-30 00:42:46 +03:00
Egor ada15065ea Merge pull request #564 from Fr1ngg/bedolaga/fix-user-statistics-in-admin-panel
Fix admin user subscription stats
2025-09-29 17:37:52 +03:00
Egor 53f1831f59 Fix admin user subscription stats 2025-09-29 17:37:20 +03:00
Egor 860ebf18b6 Merge pull request #563 from Fr1ngg/bedolaga/fix-promotion-group-mechanics-and-discounts
Fix base period discount rounding and summary formatting
2025-09-29 16:00:27 +03:00
Egor ae05ca08ea Round discounted subscription prices up and clean summary 2025-09-29 15:43:50 +03:00
Egor 88e8a56526 Merge pull request #562 from Fr1ngg/bedolaga/fix-transaction-history-logging-for-admin-actions
Log admin-triggered balance deductions in transaction history
2025-09-29 15:11:05 +03:00
Egor fb4d714441 Log admin balance deductions as transactions 2025-09-29 15:10:47 +03:00
Egor 2690f63a82 Merge pull request #561 from Fr1ngg/bedolaga/update-subscription-pricing-calculation
Fix admin subscription purchase cost calculation
2025-09-29 14:59:02 +03:00
Egor ec9855a0a9 Fix admin subscription price calculation 2025-09-29 14:57:47 +03:00
Egor 984ad8e448 Merge pull request #560 from Fr1ngg/bedolaga/add-traffic-reset-on-subscription-payment
Add configurable traffic reset on subscription payments
2025-09-29 14:41:06 +03:00
Egor ca81b32502 Add configurable traffic reset on payments 2025-09-29 14:40:36 +03:00
Egor 70f190501b Merge pull request #559 from Fr1ngg/bedolaga/fix-automation-migration-errors
Fix promo group migration defaults for addon discounts
2025-09-29 14:21:46 +03:00
Egor 432efdbb7c Fix promo group seeding for new databases 2025-09-29 14:21:23 +03:00
Egor 83a821a591 Merge pull request #558 from Fr1ngg/bedolaga/add-trial-and-subscription-days-summation
Add setting to roll remaining trial time into paid subscriptions
2025-09-29 14:02:33 +03:00
Egor 14bdfd4a1e Add configurable trial rollover for paid subscriptions 2025-09-29 14:02:14 +03:00
Egor 66ff2a38d3 Merge pull request #557 from Fr1ngg/bedolaga/update-subscription-link-handling
Respect hidden subscription link setting
2025-09-29 13:40:46 +03:00
Egor 9f7c9cad38 feat: respect hidden subscription link setting 2025-09-29 13:40:27 +03:00
Egor 2132226b15 Merge pull request #556 from Fr1ngg/revert-555-bedolaga/hide-subscription-link-in-notifications
Revert "Hide subscription link when configured"
2025-09-29 13:16:49 +03:00
Egor 849465b2e1 Revert "Hide subscription link when configured" 2025-09-29 13:16:38 +03:00
Egor 402b31625b Merge pull request #555 from Fr1ngg/bedolaga/hide-subscription-link-in-notifications
Hide subscription link when configured
2025-09-29 13:12:42 +03:00
Egor 5fc24dbfae Hide subscription link in success messages when configured 2025-09-29 13:12:24 +03:00
Egor 8ba65cc2fe Merge pull request #554 from Fr1ngg/main
w
2025-09-29 13:02:20 +03:00
Egor ee2bffe08e Merge pull request #550 from PEDZEO/feature/ticket-user-info
Исправил поведение кнопок
2025-09-29 12:58:48 +03:00
PEDZEO c4b5db9002 fix(validation): sanitize имён + HTML-escape только в /start 2025-09-29 02:17:19 +03:00
PEDZEO 1a78bdc194 Исправил поведение кнопок 2025-09-29 01:31:49 +03:00
Egor a3bb749a6a Merge pull request #549 from Fr1ngg/main
w
2025-09-28 22:39:25 +03:00
Egor 626f78b230 Merge pull request #548 from PEDZEO/feature/ticket-user-info
Feature/ticket user info
2025-09-28 22:21:47 +03:00
PEDZEO 5d44285c0a feat(support/tickets): Telegram ID/username в уведомлениях/админке, кнопки «Профиль/ЛС», возврат к тикету 2025-09-28 20:31:47 +03:00
PEDZEO 2d8091ddcb feat(support/tickets): Telegram ID/username в уведомлениях/админке, кнопки «Профиль/ЛС», возврат к тикету 2025-09-28 20:31:29 +03:00
Egor fa6a4698ab Merge pull request #546 from FireWookie/dev2
Update API
2025-09-28 16:41:40 +03:00
firewookie f40454313f add ads compaigns remote route 2025-09-28 14:21:53 +05:00
firewookie 8a256b1bc2 add broadcast service 2025-09-28 14:05:35 +05:00
firewookie 71ac7ea464 backup logic and add documentation to stats route 2025-09-28 14:04:17 +05:00
firewookie bbc9f93429 update api 2025-09-28 13:36:53 +05:00
firewookie 3ba9598b5e update api 2025-09-28 13:35:13 +05:00
firewookie ffa51986a8 env to json 2025-09-28 13:34:43 +05:00
Egor 7e20140a00 Merge pull request #544 from Fr1ngg/bedolaga/fix-bot-token-authorization-issue-j3pfif
Add fallback for bootstrap API token authentication
2025-09-28 06:28:06 +03:00
Egor 50d080f54e Improve web API token authentication fallback 2025-09-28 06:27:38 +03:00
Egor 29829b1c8c Merge pull request #543 from Fr1ngg/revert-542-bedolaga/fix-bot-token-authorization-issue
Revert "Ensure bootstrap web API token works without migration"
2025-09-28 06:26:47 +03:00
Egor e84e707d2f Revert "Ensure bootstrap web API token works without migration" 2025-09-28 06:26:37 +03:00
Egor c12566ae53 Merge pull request #542 from Fr1ngg/bedolaga/fix-bot-token-authorization-issue
Ensure bootstrap web API token works without migration
2025-09-28 06:24:31 +03:00
Egor e6976b5262 Handle bootstrap API token fallback 2025-09-28 06:24:13 +03:00
Egor 81cfdcf7ac Merge pull request #541 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api-jxb0f9
Synchronize bootstrap web API token with config overrides
2025-09-28 06:16:38 +03:00
Egor 9c1ca5b748 Ensure default web API token stays in sync with settings 2025-09-28 06:16:13 +03:00
Egor a0d14f97f4 Merge pull request #540 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api-mvwipi
Handle missing RemnaWave dependencies in web API
2025-09-28 06:03:25 +03:00
Egor 8fdc70fdc3 Handle optional RemnaWave dependencies in web API 2025-09-28 06:03:11 +03:00
Egor d93139a458 Merge pull request #539 from Fr1ngg/revert-538-bedolaga/add-admin-panel-functionality-to-bot-api
Revert "Handle optional RemnaWave dependencies in web API"
2025-09-28 06:02:51 +03:00
Egor b78dc12db9 Revert "Handle optional RemnaWave dependencies in web API" 2025-09-28 06:02:42 +03:00
Egor 2a02f589bf Merge pull request #538 from Fr1ngg/bedolaga/add-admin-panel-functionality-to-bot-api
Handle optional RemnaWave dependencies in web API
2025-09-28 06:00:37 +03:00
Egor bcfa4f8400 Handle optional RemnaWave dependencies in web API 2025-09-28 06:00:18 +03:00
Egor 042dc900ef Merge pull request #537 from Fr1ngg/bedolaga/add-endpoint-information-to-redoc
Fix OpenAPI export for RemnaWave endpoints
2025-09-28 05:45:49 +03:00
Egor 6004607bf6 Update __init__.py 2025-09-28 05:45:41 +03:00
Egor 40e4b1ce7f Ensure RemnaWave routes are imported for OpenAPI 2025-09-28 05:45:09 +03:00
Egor a4c5a2295f Merge pull request #536 from Fr1ngg/bedolaga/fix-missing-remnawave-endpoints-in-docs
Fix RemnaWave API docs registration
2025-09-28 05:29:01 +03:00
Egor 3d9a75ff51 Fix RemnaWave API docs registration 2025-09-28 05:28:41 +03:00
Egor 1216a4dd9d Merge pull request #535 from Fr1ngg/bedolaga/fix-documentation-for-remnawave-endpoints-6ae7or
Add RemnaWave API documentation to Swagger
2025-09-28 05:09:45 +03:00
Egor 4bc1c402e9 feat: document remnawave endpoints in swagger 2025-09-28 05:09:24 +03:00
Egor 42a758bd68 Merge pull request #534 from Fr1ngg/revert-533-bedolaga/fix-documentation-for-remnawave-endpoints
Revert "Fix RemnaWave routes visibility in docs"
2025-09-28 05:08:18 +03:00
Egor 07a0d0ca43 Revert "Fix RemnaWave routes visibility in docs" 2025-09-28 05:08:08 +03:00
Egor f53b04444b Merge pull request #533 from Fr1ngg/bedolaga/fix-documentation-for-remnawave-endpoints
Fix RemnaWave routes visibility in docs
2025-09-28 05:06:32 +03:00
Egor 9877c00c92 Fix RemnaWave routes visibility in docs 2025-09-28 05:06:16 +03:00
Egor 14aa175193 Merge pull request #532 from Fr1ngg/revert-529-revert-527-bedolaga/add-api-interaction-for-remnawave-components
Revert "Revert "Add RemnaWave management endpoints to web API""
2025-09-28 04:57:19 +03:00
Egor 562766a637 Revert "Revert "Add RemnaWave management endpoints to web API"" 2025-09-28 04:57:10 +03:00
Egor 4616911189 Merge pull request #531 from Fr1ngg/revert-530-bedolaga/add-api-interaction-for-remnawave-components-1yj53d
Revert "Add Remnawave component management API"
2025-09-28 04:56:43 +03:00
Egor 1e14a0919f Revert "Add Remnawave component management API" 2025-09-28 04:56:33 +03:00
Egor e1f0bd152c Merge pull request #530 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components-1yj53d
Add Remnawave component management API
2025-09-28 04:53:36 +03:00
Egor c10e34ad43 Add Remnawave component management API 2025-09-28 04:53:01 +03:00
Egor 714f742560 Merge pull request #529 from Fr1ngg/revert-527-bedolaga/add-api-interaction-for-remnawave-components
Revert "Add RemnaWave management endpoints to web API"
2025-09-28 04:51:51 +03:00
Egor dc5e440fe2 Revert "Add RemnaWave management endpoints to web API" 2025-09-28 04:51:42 +03:00
Egor d53873dc7f Merge pull request #527 from Fr1ngg/bedolaga/add-api-interaction-for-remnawave-components
Add RemnaWave management endpoints to web API
2025-09-28 04:46:12 +03:00
Egor 10ab557d8d feat(webapi): add remnawave integration endpoints 2025-09-28 04:45:57 +03:00
Egor 81147d42a9 Merge pull request #526 from Fr1ngg/revert-525-bedolaga/-scalar-redoc-api-osbdld
Revert "Replace ReDoc docs with Scalar UI"
2025-09-28 04:30:48 +03:00
Egor 8d5e2037d3 Revert "Replace ReDoc docs with Scalar UI" 2025-09-28 04:30:38 +03:00
Egor 7f03e61b5f Merge pull request #525 from Fr1ngg/bedolaga/-scalar-redoc-api-osbdld
Replace ReDoc docs with Scalar UI
2025-09-28 04:28:00 +03:00
Egor 870ee2fb66 Replace ReDoc docs with Scalar UI 2025-09-28 04:27:43 +03:00
Egor a525079282 Merge pull request #524 from Fr1ngg/revert-523-bedolaga/-scalar-redoc-api
Revert "Replace ReDoc with Scalar API reference"
2025-09-28 04:27:06 +03:00
Egor 39aff2bc1b Revert "Replace ReDoc with Scalar API reference" 2025-09-28 04:26:57 +03:00
Egor b4dc9f1970 Merge pull request #523 from Fr1ngg/bedolaga/-scalar-redoc-api
Replace ReDoc with Scalar API reference
2025-09-28 04:24:32 +03:00
Egor 37ac3e7a81 Replace ReDoc with Scalar API reference 2025-09-28 04:24:13 +03:00
Egor e3e6428d2a Merge pull request #522 from Fr1ngg/bedolaga/enable-swagger-for-api-in-bot
docs: explain enabling swagger ui
2025-09-28 04:20:43 +03:00
Egor 3e3385d1eb docs: explain enabling swagger ui 2025-09-28 04:20:28 +03:00
Egor 0375e74227 Merge pull request #521 from Fr1ngg/revert-505-revert-504-bedolaga/-docs
Revert "Revert "Enable API key auth in web API docs""
2025-09-28 04:02:41 +03:00
Egor b6bdeeefdd Revert "Revert "Enable API key auth in web API docs"" 2025-09-28 04:02:28 +03:00
Egor 24b3660f7b Merge pull request #520 from Fr1ngg/bedolaga/fix-bot-startup-issue
Fix database URL initialization
2025-09-28 03:56:01 +03:00
Egor da2c30cd8d Fix database URL initialization 2025-09-28 03:55:43 +03:00
Egor 1fad95c0bb Merge pull request #519 from Fr1ngg/revert-518-bedolaga/fix-bot-startup-validation-errors-f8krj6
Revert "Allow bot to boot without RemnaWave API settings"
2025-09-28 03:50:14 +03:00
Egor 6a1b1a9a91 Revert "Allow bot to boot without RemnaWave API settings" 2025-09-28 03:50:06 +03:00
Egor 9abf0b6ebe Merge pull request #518 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-f8krj6
Allow bot to boot without RemnaWave API settings
2025-09-28 03:46:33 +03:00
Egor 34e90b335b Handle missing RemnaWave configuration 2025-09-28 03:46:12 +03:00
Egor e84c82f29d Merge pull request #517 from Fr1ngg/revert-506-bedolaga/fix-bot-startup-error-due-to-missing-database_url
Revert "Make DATABASE_URL optional for default setup"
2025-09-28 03:38:31 +03:00
Egor 1c929a1a70 Revert "Make DATABASE_URL optional for default setup" 2025-09-28 03:38:23 +03:00
Egor 1c8878e651 Merge pull request #516 from Fr1ngg/revert-507-bedolaga/fix-missing-database_url-in-settings
Revert "Fix default handling for DATABASE_URL setting"
2025-09-28 03:37:53 +03:00
Egor 84645d84f7 Revert "Fix default handling for DATABASE_URL setting" 2025-09-28 03:37:44 +03:00
Egor 0cc59501bc Merge pull request #515 from Fr1ngg/revert-510-bedolaga/fix-missing-database_url-in-settings-wqxv3w
Revert "Allow optional Remnawave integration settings"
2025-09-28 03:36:54 +03:00
Egor ef6aaa58ea Revert "Allow optional Remnawave integration settings" 2025-09-28 03:36:42 +03:00
Egor 942ce48241 Merge pull request #514 from Fr1ngg/revert-513-bedolaga/fix-bot-startup-validation-errors-2waops
Revert "Handle missing optional RemnaWave settings"
2025-09-28 03:36:19 +03:00
Egor 625ec43c34 Revert "Handle missing optional RemnaWave settings" 2025-09-28 03:36:10 +03:00
Egor f184303415 Merge pull request #513 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors-2waops
Handle missing optional RemnaWave settings
2025-09-28 03:35:02 +03:00
Egor 84a17864cd Handle missing optional RemnaWave settings 2025-09-28 03:34:44 +03:00
Egor 1476247519 Merge pull request #512 from Fr1ngg/revert-511-bedolaga/fix-bot-startup-validation-errors
Revert "Fix missing defaults for optional Remnawave settings"
2025-09-28 03:34:05 +03:00
Egor 0ea95005ee Revert "Fix missing defaults for optional Remnawave settings" 2025-09-28 03:33:56 +03:00
Egor ba808f66da Merge pull request #511 from Fr1ngg/bedolaga/fix-bot-startup-validation-errors
Fix missing defaults for optional Remnawave settings
2025-09-28 03:32:42 +03:00
Egor e8472a502f Make optional Remnawave settings nullable by default 2025-09-28 03:32:23 +03:00
Egor 7d552ab715 Merge pull request #510 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings-wqxv3w
Allow optional Remnawave integration settings
2025-09-28 03:27:55 +03:00
Egor 00de3a41df Allow optional Remnawave integration settings 2025-09-28 03:27:25 +03:00
Egor 36f38187d9 Merge pull request #509 from Fr1ngg/revert-508-bedolaga/fix-missing-database_url-in-settings-gvqm03
Revert "Make Remnawave settings optional by default"
2025-09-28 03:27:12 +03:00
Egor dd68b2b788 Revert "Make Remnawave settings optional by default" 2025-09-28 03:27:02 +03:00
Egor 7a9d37c884 Merge pull request #508 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings-gvqm03
Make Remnawave settings optional by default
2025-09-28 03:25:20 +03:00
Egor 6a904d4be2 Make Remnawave settings optional by default 2025-09-28 03:25:04 +03:00
Egor b5b8408055 Merge pull request #507 from Fr1ngg/bedolaga/fix-missing-database_url-in-settings
Fix default handling for DATABASE_URL setting
2025-09-28 03:20:07 +03:00
Egor 86a3228579 Fix default handling for DATABASE_URL setting 2025-09-28 03:19:52 +03:00
Egor 560993c0f3 Merge pull request #506 from Fr1ngg/bedolaga/fix-bot-startup-error-due-to-missing-database_url
Make DATABASE_URL optional for default setup
2025-09-28 03:11:15 +03:00
Egor a3fe935149 Make DATABASE_URL optional in settings 2025-09-28 03:10:57 +03:00
Egor a75549aebf Merge pull request #505 from Fr1ngg/revert-504-bedolaga/-docs
Revert "Enable API key auth in web API docs"
2025-09-28 03:02:31 +03:00
Egor be3fc7aa09 Revert "Enable API key auth in web API docs" 2025-09-28 03:02:22 +03:00
Egor 6cbfc6d74b Merge pull request #504 from Fr1ngg/bedolaga/-docs
Enable API key auth in web API docs
2025-09-28 02:59:25 +03:00
Egor 7ff7a051d9 Enable API key auth in web API docs 2025-09-28 02:59:07 +03:00
Egor 9a2f156158 Merge pull request #503 from Fr1ngg/bedolaga/fix-err_empty_response-on-api-endpoint
Expose admin API port for docker deployments
2025-09-28 02:48:58 +03:00
Egor 38b058636c Expose admin API port in docker compose 2025-09-28 02:48:42 +03:00
Egor 32523f5dea Merge pull request #502 from Fr1ngg/bedolaga/fix-web-api-startup-error
Handle uvicorn startup flag regression for admin web API
2025-09-28 02:32:09 +03:00
Egor ff70e38dbc Fix web API startup wait for uvicorn 0.32 2025-09-28 02:31:56 +03:00
Egor 8110f52bc3 Merge pull request #501 from Fr1ngg/bedolaga/fix-bot-api-launch-issue-3qh95y
Fix FastAPI 204 delete endpoints to return empty responses
2025-09-28 02:25:43 +03:00
Egor a5a0176433 Fix 204 responses to avoid response bodies 2025-09-28 02:25:26 +03:00
Egor 2d960e3e30 Merge pull request #500 from Fr1ngg/revert-499-bedolaga/fix-bot-api-launch-issue
Revert "Fix 204 responses for web API deletes"
2025-09-28 02:25:10 +03:00
Egor b5fa978023 Revert "Fix 204 responses for web API deletes" 2025-09-28 02:25:01 +03:00
Egor 9cc49e9f66 Merge pull request #499 from Fr1ngg/bedolaga/fix-bot-api-launch-issue
Fix 204 responses for web API deletes
2025-09-28 02:24:17 +03:00
Egor dbf63e518f Fix 204 responses for web API deletes 2025-09-28 02:23:58 +03:00
Egor 3e218b6e6d Merge pull request #498 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions-vi0z1h
Handle RemnaWave server removal and add connected users view
2025-09-28 02:08:25 +03:00
Egor 2f52f6b8de Remove missing RemnaWave servers and list connected users 2025-09-28 02:08:05 +03:00
Egor 5d377cf885 Merge pull request #497 from Fr1ngg/revert-496-bedolaga/fix-server-deletion-issue-in-subscriptions-cd8nse
Revert "Handle removed RemnaWave squads and expose server users"
2025-09-28 02:07:50 +03:00
Egor 62ecacc3cc Revert "Handle removed RemnaWave squads and expose server users" 2025-09-28 02:07:43 +03:00
Egor 4ab6d45e50 Merge pull request #496 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions-cd8nse
Handle removed RemnaWave squads and expose server users
2025-09-28 02:02:55 +03:00
Egor d1bed5e41a Handle removed RemnaWave squads and expose server users 2025-09-28 02:02:37 +03:00
Egor b907f884ba Merge pull request #495 from Fr1ngg/revert-494-bedolaga/fix-server-deletion-issue-in-subscriptions
Revert "Handle RemnaWave server removal and expose server user list"
2025-09-28 02:02:08 +03:00
Egor 83a76c928d Revert "Handle RemnaWave server removal and expose server user list" 2025-09-28 02:01:57 +03:00
Egor 90d3f3aef2 Merge pull request #494 from Fr1ngg/bedolaga/fix-server-deletion-issue-in-subscriptions
Handle RemnaWave server removal and expose server user list
2025-09-28 01:56:59 +03:00
Egor 263df606d2 Remove missing RemnaWave servers during sync and add user list view 2025-09-28 01:56:40 +03:00
Egor 2a38969e3d Merge pull request #493 from Fr1ngg/main
w
2025-09-28 01:50:53 +03:00
Egor 300feec18e Merge pull request #492 from Fr1ngg/dev3
Dev3
2025-09-28 01:38:14 +03:00
Egor 7c56452eb4 Merge pull request #491 from Fr1ngg/bedolaga/fix-payment-button-and-status-issues
Fix PayPalych SBP payments
2025-09-28 01:34:02 +03:00
Egor 548ec540f2 Fix PayPalych SBP payments 2025-09-28 01:33:38 +03:00
Egor bceba34cc3 Merge pull request #490 from Fr1ngg/main
w
2025-09-28 01:14:29 +03:00
Egor b07ce2be5c Merge pull request #487 from yazhog/main
Пропуск выбора стран при оформлении подписки если доступен только 1 сквад, фикс уведомлений при пополнении баланса
2025-09-28 01:13:50 +03:00
Egor 9472cf5e8c Merge pull request #484 from PEDZEO/feature/ticket-report
Добавлен отчет по тикетам
2025-09-28 01:12:51 +03:00
yazhog 82f34e8c6e Merge pull request #44 from yazhog/codex/update-payment-options-layout
Reorder subscription return button in top-up selection
2025-09-27 16:48:18 +03:00
yazhog 02b718dac5 Adjust top-up keyboard button order 2025-09-27 16:44:52 +03:00
yazhog a65a23acb2 Merge pull request #43 from yazhog/codex/refactor-admin-notification-subscription-handling
Avoid lazy subscription load in admin notifications
2025-09-27 15:58:11 +03:00
yazhog 12523e7515 Avoid lazy subscription load in admin notifications 2025-09-27 15:54:38 +03:00
yazhog d3497f924a Merge pull request #42 from yazhog/codex/skip-country-selection-if-one-available
Skip country selection step when only one option is available
2025-09-27 13:29:20 +03:00
yazhog 75faa54b85 Skip country selection when only one option 2025-09-27 13:10:03 +03:00
Pedzeo d22f9aaf62 Добавлен отчет по тикетам 2025-09-26 17:14:00 +03:00
Egor 68f5e2ab80 Add files via upload 2025-09-26 15:17:29 +03:00
Egor 4e2b71cb85 Update Dockerfile 2025-09-26 05:42:14 +03:00
Egor e229a7ee65 Merge pull request #480 from Fr1ngg/bedolaga/update-api-bot-for-web-admin-integration
Document and type admin web API integration
2025-09-26 05:37:28 +03:00
Egor abf1f53218 Add web admin API schemas and docs 2025-09-26 05:37:08 +03:00
Egor 2859219f80 Merge pull request #479 from Fr1ngg/revert-478-revert-477-bedolaga/add-api-for-bot-integration-vlrm04
Revert 478 revert 477 bedolaga/add api for bot integration vlrm04
2025-09-26 05:18:55 +03:00
Egor e71b7f4533 Revert "Revert "Add administrative web API and database support"" 2025-09-26 05:18:41 +03:00
Egor 5ba652fab6 Merge pull request #478 from Fr1ngg/revert-477-bedolaga/add-api-for-bot-integration-vlrm04
Revert "Add administrative web API and database support"
2025-09-26 05:18:32 +03:00
Egor 0a3913a2aa Revert "Add administrative web API and database support" 2025-09-26 05:18:21 +03:00
Egor 118ef8a29f Merge pull request #477 from Fr1ngg/bedolaga/add-api-for-bot-integration-vlrm04
Add administrative web API and database support
2025-09-26 05:17:33 +03:00
Egor b5f7e06605 Add administrative web API and database support 2025-09-26 05:16:58 +03:00
Egor d258d27406 Merge pull request #476 from Fr1ngg/revert-475-revert-474-revert-473-bedolaga/add-api-for-bot-integration
Revert "Revert 474 revert 473 bedolaga/add api for bot integration"
2025-09-26 05:16:43 +03:00
Egor 554fb28cdb Revert "Revert 474 revert 473 bedolaga/add api for bot integration" 2025-09-26 05:16:36 +03:00
Egor 808901c38b Merge pull request #475 from Fr1ngg/revert-474-revert-473-bedolaga/add-api-for-bot-integration
Revert 474 revert 473 bedolaga/add api for bot integration
2025-09-26 05:11:38 +03:00
Egor 68508d8a86 Revert "Revert "Add web admin API for bot management"" 2025-09-26 05:11:13 +03:00
Egor 0daf8d7691 Merge pull request #474 from Fr1ngg/revert-473-bedolaga/add-api-for-bot-integration
Revert "Add web admin API for bot management"
2025-09-26 05:10:12 +03:00
Egor 5aeb2038c5 Revert "Add web admin API for bot management" 2025-09-26 05:09:59 +03:00
Egor d3a1060117 Merge pull request #473 from Fr1ngg/bedolaga/add-api-for-bot-integration
Add web admin API for bot management
2025-09-26 05:09:54 +03:00
Egor 4d22e99076 feat: add web admin api server 2025-09-26 05:09:26 +03:00
Egor 879607982e Update Dockerfile 2025-09-26 00:23:25 +03:00
Egor 5482b31fcc Update docker-registry.yml 2025-09-26 00:23:14 +03:00
Egor f5be0e6807 Update docker-hub.yml 2025-09-26 00:22:56 +03:00
Egor c3a60871c9 Merge pull request #444 from Fr1ngg/dev2
Тест платежек в настройках конфигурации бота + перенос обязательных переменных ремны из env
2025-09-25 23:47:48 +03:00
Egor 2628444866 Merge pull request #443 from Fr1ngg/bedolaga/fix-invalid-payload-error-during-star-balance-test
Allow admin Stars test invoices
2025-09-25 23:44:30 +03:00
Egor c1a7360c66 Allow admin Stars test invoices 2025-09-25 23:44:14 +03:00
Egor b8c2bdc9e9 Merge pull request #442 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings-331od6
Add payment testing actions to admin bot configuration
2025-09-25 23:39:58 +03:00
Egor 573735ffa9 Add payment testing actions to admin bot configuration 2025-09-25 23:39:41 +03:00
Egor 9b3a9883b0 Merge pull request #441 from Fr1ngg/revert-440-bedolaga/add-payment-testing-options-in-admin-settings-l7liam
Revert "Add payment testing actions to admin bot configuration"
2025-09-25 23:39:25 +03:00
Egor 8cc54d376a Revert "Add payment testing actions to admin bot configuration" 2025-09-25 23:39:18 +03:00
Egor 26910dbbcc Merge pull request #440 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings-l7liam
Add payment testing actions to admin bot configuration
2025-09-25 23:38:21 +03:00
Egor b131fea7d1 Add payment testing actions to admin bot configuration 2025-09-25 23:38:05 +03:00
Egor 84bc36d855 Merge pull request #439 from Fr1ngg/revert-438-bedolaga/add-payment-testing-options-in-admin-settings-5pai8v
Revert "Add payment testing actions to admin bot configuration"
2025-09-25 23:37:51 +03:00
Egor ea5bd22c28 Revert "Add payment testing actions to admin bot configuration" 2025-09-25 23:37:43 +03:00
Egor e9f1064d73 Merge pull request #438 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings-5pai8v
Add payment testing actions to admin bot configuration
2025-09-25 23:36:24 +03:00
Egor f3cba203dd Add payment testing actions to admin bot configuration 2025-09-25 23:36:08 +03:00
Egor 0ff15850b7 Merge pull request #437 from Fr1ngg/revert-436-bedolaga/add-payment-testing-options-in-admin-settings
Revert "Add payment testing tools to admin configuration"
2025-09-25 23:35:47 +03:00
Egor 383563c399 Revert "Add payment testing tools to admin configuration" 2025-09-25 23:35:38 +03:00
Egor 40bbb54f56 Merge pull request #436 from Fr1ngg/bedolaga/add-payment-testing-options-in-admin-settings
Add payment testing tools to admin configuration
2025-09-25 23:32:35 +03:00
Egor a09eafb9c1 Add admin payment testing tools 2025-09-25 23:32:16 +03:00
Egor 98b15d5c82 Merge pull request #435 from Fr1ngg/bedolaga/move-remnawave-settings-to-admin-config
Make RemnaWave settings configurable via admin panel
2025-09-25 23:16:49 +03:00
Egor b5cb547fe2 Make RemnaWave settings configurable via admin panel 2025-09-25 23:16:31 +03:00
Egor a7af39fb34 Merge pull request #434 from Fr1ngg/dev2
Динамическая конфигурация бота в бд)()()()(
2025-09-25 22:09:40 +03:00
Egor 02fa7de016 Merge pull request #433 from Fr1ngg/bedolaga/reorganize-bot-admin-sections-and-submenus
Reorganize bot configuration categories
2025-09-25 21:20:08 +03:00
Egor b14ee9a6a4 Reorganize bot configuration categories 2025-09-25 21:19:42 +03:00
Egor be2181c4cb Merge pull request #432 from Fr1ngg/bedolaga/fix-bot-settings-editing-issues-zs6d4j
Fix admin config callbacks and add selectable options
2025-09-25 21:08:20 +03:00
Egor 6cd65812ac Fix admin config callbacks and add selectable options 2025-09-25 21:08:01 +03:00
Egor b282fd1525 Merge pull request #431 from Fr1ngg/revert-430-bedolaga/fix-bot-settings-editing-issues
Revert "Fix bot config callbacks and add selectable options"
2025-09-25 21:07:30 +03:00
Egor 7c38e672e3 Revert "Fix bot config callbacks and add selectable options" 2025-09-25 21:07:21 +03:00
Egor c2685476b0 Merge pull request #430 from Fr1ngg/bedolaga/fix-bot-settings-editing-issues
Fix bot config callbacks and add selectable options
2025-09-25 20:52:24 +03:00
Egor a80e2176de Fix bot config callbacks and add selectable options 2025-09-25 20:52:09 +03:00
Egor bf4de82442 Merge pull request #429 from Fr1ngg/bedolaga/fix-bot-configuration-command-handling-d8gjen
Allow admins to update bot config via plain text input
2025-09-25 18:16:18 +03:00
Egor 22c736a41c Allow direct admin bot config updates via text input 2025-09-25 18:16:01 +03:00
Egor a1c5f9d987 Merge pull request #428 from Fr1ngg/revert-427-bedolaga/fix-bot-configuration-command-handling-9kzryd
Revert "Enable direct bot configuration edits from setting view"
2025-09-25 18:15:48 +03:00
Egor 1ca1df1403 Revert "Enable direct bot configuration edits from setting view" 2025-09-25 18:15:36 +03:00
Egor be3b554cde Merge pull request #427 from Fr1ngg/bedolaga/fix-bot-configuration-command-handling-9kzryd
Enable direct bot configuration edits from setting view
2025-09-25 18:14:28 +03:00
Egor efc8f04836 Enable direct bot configuration edits from setting view 2025-09-25 18:14:12 +03:00
Egor 1cf7138430 Merge pull request #426 from Fr1ngg/revert-425-bedolaga/fix-bot-configuration-command-handling
Revert "Allow direct editing of bot config settings"
2025-09-25 18:13:56 +03:00
Egor 64c8145f1a Revert "Allow direct editing of bot config settings" 2025-09-25 18:13:47 +03:00
Egor dc62ad927d Merge pull request #425 from Fr1ngg/bedolaga/fix-bot-configuration-command-handling
Allow direct editing of bot config settings
2025-09-25 18:13:13 +03:00
Egor 61188d8a97 Allow direct editing of bot config settings 2025-09-25 18:12:57 +03:00
Egor f92d5e996e Merge pull request #424 from Fr1ngg/revert-419-revert-418-bedolaga/improve-bot-settings-layout
Revert "Revert "Improve bot configuration admin menu grouping""
2025-09-25 18:04:14 +03:00
Egor 817fb82476 Revert "Revert "Improve bot configuration admin menu grouping"" 2025-09-25 18:04:04 +03:00
Egor 9635f9464e Merge pull request #423 from Fr1ngg/revert-422-bedolaga/improve-bot-settings-layout-yxarpo
Revert "Improve admin bot configuration menu grouping and labels"
2025-09-25 18:03:56 +03:00
Egor 33e0d58e4d Revert "Improve admin bot configuration menu grouping and labels" 2025-09-25 18:03:48 +03:00
Egor 5fb951328d Merge pull request #422 from Fr1ngg/bedolaga/improve-bot-settings-layout-yxarpo
Improve admin bot configuration menu grouping and labels
2025-09-25 18:00:50 +03:00
Egor 5f5255d107 Improve admin bot configuration menu grouping and labels 2025-09-25 18:00:26 +03:00
Egor 4bf7f83f1e Merge pull request #421 from Fr1ngg/revert-420-bedolaga/improve-bot-settings-layout-z8kptq
Revert "Improve admin bot configuration navigation clarity"
2025-09-25 17:59:03 +03:00
Egor cce6d99a74 Revert "Improve admin bot configuration navigation clarity" 2025-09-25 17:58:53 +03:00
Egor 4060425ac4 Merge pull request #420 from Fr1ngg/bedolaga/improve-bot-settings-layout-z8kptq
Improve admin bot configuration navigation clarity
2025-09-25 17:50:21 +03:00
Egor 5023bec388 Fix imports for bot config grouping 2025-09-25 17:50:07 +03:00
Egor e0d90874bd Merge pull request #419 from Fr1ngg/revert-418-bedolaga/improve-bot-settings-layout
Revert "Improve bot configuration admin menu grouping"
2025-09-25 17:49:47 +03:00
Egor dd62ba1efd Revert "Improve bot configuration admin menu grouping" 2025-09-25 17:49:37 +03:00
Egor ceb8191b43 Merge pull request #418 from Fr1ngg/bedolaga/improve-bot-settings-layout
Improve bot configuration admin menu grouping
2025-09-25 17:46:25 +03:00
Egor da2e55bb25 Improve bot configuration admin menu grouping 2025-09-25 17:46:09 +03:00
Egor 8a3a990e39 Merge pull request #417 from Fr1ngg/revert-412-revert-411-bedolaga/move-env-settings-to-admin-panel
Revert "Revert "Add admin bot configuration management UI""
2025-09-25 17:11:32 +03:00
Egor e3cd2b0b92 Revert "Revert "Add admin bot configuration management UI"" 2025-09-25 17:11:15 +03:00
Egor 1777128590 Merge pull request #416 from Fr1ngg/revert-415-bedolaga/move-env-settings-to-admin-panel-zgsjdr
Revert "Add admin-configurable runtime settings"
2025-09-25 17:10:53 +03:00
Egor 04a5fe9d77 Revert "Add admin-configurable runtime settings" 2025-09-25 17:10:41 +03:00
Egor 595a4eb3a3 Merge pull request #415 from Fr1ngg/bedolaga/move-env-settings-to-admin-panel-zgsjdr
Add admin-configurable runtime settings
2025-09-25 17:04:51 +03:00
Egor ede9259347 Import json in app settings service 2025-09-25 17:04:34 +03:00
Egor d87556fd9f Merge pull request #414 from Fr1ngg/revert-413-bedolaga/move-env-settings-to-admin-panel-qubb3n
Revert "Add admin-managed bot configuration settings"
2025-09-25 16:58:03 +03:00
Egor 198ce23625 Revert "Add admin-managed bot configuration settings" 2025-09-25 16:57:55 +03:00
Egor 1be83b7b48 Merge pull request #413 from Fr1ngg/bedolaga/move-env-settings-to-admin-panel-qubb3n
Add admin-managed bot configuration settings
2025-09-25 16:56:27 +03:00
Egor 2b2b94936f Add admin-managed bot configuration settings 2025-09-25 16:55:45 +03:00
Egor 84df8f727e Merge pull request #412 from Fr1ngg/revert-411-bedolaga/move-env-settings-to-admin-panel
Revert "Add admin bot configuration management UI"
2025-09-25 16:42:39 +03:00
Egor 84aaaf6254 Revert "Add admin bot configuration management UI" 2025-09-25 16:42:30 +03:00
Egor ce867efaea Merge pull request #411 from Fr1ngg/bedolaga/move-env-settings-to-admin-panel
Add admin bot configuration management UI
2025-09-25 16:39:57 +03:00
Egor 492d9f5170 Add admin UI for runtime bot configuration 2025-09-25 16:39:34 +03:00
Egor dcbbf7ba44 Merge pull request #410 from Fr1ngg/dev2
Деактивация тральной подписки в случае отписки от канала
2025-09-25 16:09:00 +03:00
Egor 04e7f818c3 Merge pull request #409 from Fr1ngg/bedolaga/improve-subscription-verification-service
Handle trial subscription status on channel membership changes
2025-09-25 16:05:47 +03:00
Egor 2713bdf08e Handle trial subscription status on channel membership changes 2025-09-25 16:05:30 +03:00
Egor b2bfaf0e81 Merge pull request #408 from Fr1ngg/bedolaga/update-subscription-service-for-trial-users-9k5u2q
Check trial channel subscription status
2025-09-25 15:54:29 +03:00
Egor 25a4be076b Check trial channel subscription status 2025-09-25 15:54:11 +03:00
Egor cbd721a10f Merge pull request #407 from Fr1ngg/revert-406-bedolaga/update-subscription-service-for-trial-users-hese0j
Revert "Add trial channel subscription monitoring"
2025-09-25 15:54:00 +03:00
Egor 3ff8cb5e61 Revert "Add trial channel subscription monitoring" 2025-09-25 15:53:51 +03:00
Egor 6f144f64b1 Merge pull request #406 from Fr1ngg/bedolaga/update-subscription-service-for-trial-users-hese0j
Add trial channel subscription monitoring
2025-09-25 15:51:31 +03:00
Egor 93200d1450 Add trial channel subscription monitoring 2025-09-25 15:51:13 +03:00
Egor 3f60533c50 Merge pull request #405 from Fr1ngg/revert-404-bedolaga/update-subscription-service-for-trial-users
Revert "Add trial subscription channel membership monitoring"
2025-09-25 15:50:59 +03:00
Egor 41ab0abac8 Revert "Add trial subscription channel membership monitoring" 2025-09-25 15:50:49 +03:00
Egor a5e4fb24ca Merge pull request #404 from Fr1ngg/bedolaga/update-subscription-service-for-trial-users
Add trial subscription channel membership monitoring
2025-09-25 15:48:56 +03:00
Egor 6d2ce895e4 Add trial subscription channel membership monitoring 2025-09-25 15:48:40 +03:00
Egor f41a4197b6 Merge pull request #403 from Fr1ngg/dev2
Скидка на докупку доп услуг в промогруппе
2025-09-25 15:34:17 +03:00
Egor 200c728342 Merge pull request #402 from Fr1ngg/bedolaga/refactor-activation-status-toggle-in-discounts-kvwc17
Improve promo group discount UI and toggle handling
2025-09-25 15:30:46 +03:00
Egor d8571c4f3e Improve promo group discount UI and toggle handling 2025-09-25 15:30:30 +03:00
Egor 233ceaf400 Merge pull request #401 from Fr1ngg/revert-400-bedolaga/refactor-activation-status-toggle-in-discounts
Revert "Improve promo group addon discount toggle UX"
2025-09-25 15:30:10 +03:00
Egor e8bca6926a Revert "Improve promo group addon discount toggle UX" 2025-09-25 15:30:00 +03:00
Egor 9e8aecae4d Merge pull request #400 from Fr1ngg/bedolaga/refactor-activation-status-toggle-in-discounts
Improve promo group addon discount toggle UX
2025-09-25 15:27:18 +03:00
Egor e75b44695e Fix promo group discount summary formatting 2025-09-25 15:26:55 +03:00
Egor 18784b3ef7 Merge pull request #399 from Fr1ngg/bedolaga/fix-repeat-user-assignment-to-promo-group
Prevent repeated automatic promo group reassignment
2025-09-25 15:06:13 +03:00
Egor 98a241a301 Prevent repeated auto promo group assignments 2025-09-25 15:05:53 +03:00
Egor 70ee783a7a Merge pull request #398 from Fr1ngg/revert-397-bedolaga/fix-multiple-user-promo-group-assignment-b2wz1u
Revert "Track last auto promo group to avoid duplicate assignments"
2025-09-25 14:56:19 +03:00
Egor 00cd0bb5b0 Revert "Track last auto promo group to avoid duplicate assignments" 2025-09-25 14:56:08 +03:00
Egor 02b7365ba3 Merge pull request #397 from Fr1ngg/bedolaga/fix-multiple-user-promo-group-assignment-b2wz1u
Track last auto promo group to avoid duplicate assignments
2025-09-25 14:55:12 +03:00
Egor a6da530848 Track last auto promo group to avoid duplicate assignments 2025-09-25 14:54:46 +03:00
Egor f6e6af5e42 Merge pull request #395 from Fr1ngg/revert-394-bedolaga/fix-multiple-user-promo-group-assignment
Revert "Prevent auto promo group overrides after manual change"
2025-09-25 14:43:53 +03:00
Egor 6f834aeec0 Revert "Prevent auto promo group overrides after manual change" 2025-09-25 14:43:45 +03:00
Egor 8bfdde0d51 Merge pull request #394 from Fr1ngg/bedolaga/fix-multiple-user-promo-group-assignment
Prevent auto promo group overrides after manual change
2025-09-25 14:36:47 +03:00
Egor 137ac3c58c Respect manual promo group overrides 2025-09-25 14:36:26 +03:00
Egor d7a0d9f7a5 Merge pull request #393 from Fr1ngg/bedolaga/fix-server-switch-function-in-promo-group-shqdqq
Fix server management button for promo groups with multiple servers
2025-09-25 14:23:47 +03:00
Egor 9b477a700d Ensure server management button respects promo group servers 2025-09-25 14:23:32 +03:00
Egor 77a9dcf5af Merge pull request #392 from Fr1ngg/revert-391-bedolaga/fix-server-switch-function-in-promo-group-aicbmk
Revert "Fix server management visibility for promo groups"
2025-09-25 14:22:45 +03:00
Egor 1c1f7ade78 Revert "Fix server management visibility for promo groups" 2025-09-25 14:22:36 +03:00
Egor 4aa593f8c6 Merge pull request #391 from Fr1ngg/bedolaga/fix-server-switch-function-in-promo-group-aicbmk
Fix server management visibility for promo groups
2025-09-25 14:18:27 +03:00
Egor cefd39f7e9 Ensure server management button appears when multiple options exist 2025-09-25 14:18:11 +03:00
Egor cfeb8fd9a3 Merge pull request #390 from Fr1ngg/revert-389-bedolaga/fix-server-switch-function-in-promo-group
Revert "Fix server management visibility for promo groups"
2025-09-25 14:17:22 +03:00
Egor 241a053252 Revert "Fix server management visibility for promo groups" 2025-09-25 14:17:15 +03:00
Egor b53726276c Merge pull request #389 from Fr1ngg/bedolaga/fix-server-switch-function-in-promo-group
Fix server management visibility for promo groups
2025-09-25 14:16:25 +03:00
Egor 7316e4aa48 Fix server management visibility for promo groups 2025-09-25 14:16:10 +03:00
Egor 889629e3c1 Merge pull request #388 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-1ead69
Fix addon discount calculations and server availability
2025-09-25 14:06:37 +03:00
Egor fbecf5bf7a Fix addon discount calculations and server availability 2025-09-25 14:06:18 +03:00
Egor faae87212e Merge pull request #387 from Fr1ngg/revert-381-bedolaga/fix-discount-logic-for-additional-services-9agrnv
Revert "Fix addon discounts for server/device/traffic purchases"
2025-09-25 14:06:04 +03:00
Egor bfa355e956 Revert "Fix addon discounts for server/device/traffic purchases" 2025-09-25 14:05:55 +03:00
Egor a2c0001f5a Merge pull request #381 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-9agrnv
Fix addon discounts for server/device/traffic purchases
2025-09-25 14:02:54 +03:00
Egor 5fa2339018 Merge pull request #386 from Fr1ngg/revert-385-bedolaga/fix-discount-logic-for-additional-services-hdjty5
Revert "Fix add-on discount handling across subscription flows"
2025-09-25 14:01:49 +03:00
Egor 9ba183b6a6 Revert "Fix add-on discount handling across subscription flows" 2025-09-25 14:01:40 +03:00
Egor 6d142a7f38 Merge pull request #385 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-hdjty5
Fix add-on discount handling across subscription flows
2025-09-25 14:00:59 +03:00
Egor 6f157e2923 Fix addon discount application and cleanup formatting 2025-09-25 14:00:41 +03:00
Egor dccb070376 Merge pull request #384 from Fr1ngg/revert-383-bedolaga/fix-discount-logic-for-additional-services-k1wn8a
Revert "Fix addon discount calculations for extra services"
2025-09-25 13:55:14 +03:00
Egor f0b70c9424 Revert "Fix addon discount calculations for extra services" 2025-09-25 13:55:06 +03:00
Egor 9a847ec6b6 Merge pull request #383 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services-k1wn8a
Fix addon discount calculations for extra services
2025-09-25 13:52:54 +03:00
Egor 4a94d6a966 Fix addon discounts for servers, devices, and traffic 2025-09-25 13:52:26 +03:00
Egor ae654452e4 Merge pull request #382 from Fr1ngg/revert-380-bedolaga/fix-discount-logic-for-additional-services
Revert "Fix addon discounts for server/device/traffic purchases"
2025-09-25 13:51:56 +03:00
Egor 2e71fd89c9 Revert "Fix addon discounts for server/device/traffic purchases" 2025-09-25 13:51:46 +03:00
Egor caf497ed28 Merge pull request #380 from Fr1ngg/bedolaga/fix-discount-logic-for-additional-services
Fix addon discounts for server/device/traffic purchases
2025-09-25 13:49:29 +03:00
Egor bb5dd7176b Fix addon discount calculations and availability checks 2025-09-25 13:49:12 +03:00
Egor ac83a65d3b Fix addon discount calculations and availability checks 2025-09-25 13:49:02 +03:00
Egor 147cbfa625 Merge pull request #379 from Fr1ngg/revert-372-revert-371-bedolaga/add-discount-toggle-for-additional-services-8w6zvb
Revert "Revert "Add addon discount toggle for promo groups""
2025-09-25 13:31:02 +03:00
Egor 497775c382 Revert "Revert "Add addon discount toggle for promo groups"" 2025-09-25 13:30:52 +03:00
Egor 3a9b8dc0f8 Merge pull request #378 from Fr1ngg/revert-377-bedolaga/add-discount-toggle-for-additional-services-wadtco
Revert "Add promo group add-on discount toggle"
2025-09-25 13:30:36 +03:00
Egor 0fd3452dd6 Revert "Add promo group add-on discount toggle" 2025-09-25 13:30:28 +03:00
Egor 1d5c277df0 Merge pull request #377 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-wadtco
Add promo group add-on discount toggle
2025-09-25 13:26:18 +03:00
Egor 6483efc099 Add promo group add-on discount toggle 2025-09-25 13:25:58 +03:00
Egor 149a9f599d Merge pull request #376 from Fr1ngg/revert-375-bedolaga/add-discount-toggle-for-additional-services-pqdau3
Revert "Add localization for promo group add-on discount toggle"
2025-09-25 13:24:29 +03:00
Egor 1bd55c4d80 Revert "Add localization for promo group add-on discount toggle" 2025-09-25 13:24:21 +03:00
Egor ef9abe8f91 Merge pull request #375 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-pqdau3
Add localization for promo group add-on discount toggle
2025-09-25 13:21:17 +03:00
Egor 461b820ef1 feat: localize promo group addon discount toggle 2025-09-25 13:18:40 +03:00
Egor 0535353124 Merge pull request #374 from Fr1ngg/revert-373-bedolaga/add-discount-toggle-for-additional-services-ghttwf
Revert "Add promo group add-on discount toggle"
2025-09-25 13:10:12 +03:00
Egor 19804607ac Revert "Add promo group add-on discount toggle" 2025-09-25 13:10:03 +03:00
Egor 272de0bd33 Merge pull request #373 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-ghttwf
Add promo group add-on discount toggle
2025-09-25 13:07:22 +03:00
Egor b53a54a237 feat: add toggle for promo group addon discounts 2025-09-25 13:07:03 +03:00
Egor e57c6d0ade Merge pull request #372 from Fr1ngg/revert-371-bedolaga/add-discount-toggle-for-additional-services-8w6zvb
Revert "Add addon discount toggle for promo groups"
2025-09-25 13:06:02 +03:00
Egor 18556f54d3 Revert "Add addon discount toggle for promo groups" 2025-09-25 13:05:54 +03:00
Egor a5c081887e Merge pull request #371 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-8w6zvb
Add addon discount toggle for promo groups
2025-09-25 13:03:53 +03:00
Egor 80efd4ab86 Add addon discount toggle for promo groups 2025-09-25 13:03:31 +03:00
Egor 3df541689b Merge pull request #370 from Fr1ngg/revert-369-bedolaga/add-discount-toggle-for-additional-services-tx3ab5
Revert "feat: add promo group addon discount toggle"
2025-09-25 12:51:30 +03:00
Egor 06c90dc3ca Revert "feat: add promo group addon discount toggle" 2025-09-25 12:51:22 +03:00
Egor d3599a6a0c Merge pull request #369 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services-tx3ab5
feat: add promo group addon discount toggle
2025-09-25 12:46:21 +03:00
Egor afab5f2515 fix: import addon discount helper in traffic switch 2025-09-25 12:46:04 +03:00
Egor 37e7e74d28 Merge pull request #368 from Fr1ngg/revert-367-bedolaga/add-discount-toggle-for-additional-services
Revert "Add promo group toggle for add-on discounts"
2025-09-25 12:45:38 +03:00
Egor 25eff6929b Revert "Add promo group toggle for add-on discounts" 2025-09-25 12:45:24 +03:00
Egor 72888ff02d Merge pull request #367 from Fr1ngg/bedolaga/add-discount-toggle-for-additional-services
Add promo group toggle for add-on discounts
2025-09-25 12:43:47 +03:00
Egor 9c572a2771 Add promo group option for add-on discounts 2025-09-25 12:43:13 +03:00
Egor 0f6d6256a2 Merge pull request #366 from Fr1ngg/dev2
Режим happ_cryptolink
2025-09-25 12:12:48 +03:00
Egor 2191e63803 Merge pull request #365 from Fr1ngg/bedolaga/fix-missing-localization-keys
Add missing Happ subscription localization keys
2025-09-25 12:09:21 +03:00
Egor 43d75dc3ec Add missing Happ subscription localization keys 2025-09-25 12:09:04 +03:00
Egor 59cae2fde6 Update README.md 2025-09-25 11:59:48 +03:00
Egor 4f4c9e5426 Update .env.example 2025-09-25 11:59:11 +03:00
Egor df03f16a9d Merge pull request #364 from Fr1ngg/bedolaga/add-direct-connection-button-for-happ_cryptolink-95birt
Add redirect button support for Happ cryptolink mode
2025-09-25 11:53:03 +03:00
Egor 57db753218 Support Happ redirect button for cryptolink mode 2025-09-25 11:52:47 +03:00
Egor 5d4a2b51ef Merge pull request #363 from Fr1ngg/revert-362-bedolaga/add-direct-connection-button-for-happ_cryptolink
Revert "Add optional Happ redirect button for cryptolink mode"
2025-09-25 11:52:20 +03:00
Egor 67c6185f8b Revert "Add optional Happ redirect button for cryptolink mode" 2025-09-25 11:52:11 +03:00
Egor e3d18f7a1d Merge pull request #362 from Fr1ngg/bedolaga/add-direct-connection-button-for-happ_cryptolink
Add optional Happ redirect button for cryptolink mode
2025-09-25 11:51:19 +03:00
Egor 4bcefaa7b8 Add Happ redirect support for cryptolink mode 2025-09-25 11:50:30 +03:00
Egor 9d9551c072 Merge pull request #360 from Fr1ngg/revert-359-bedolaga/fix-unsupported-url-protocol-error-6s98ne
Revert "Handle unsupported Happ cryptolink URLs in Telegram"
2025-09-25 11:39:42 +03:00
Egor 4e6bc5ae71 Revert "Handle unsupported Happ cryptolink URLs in Telegram" 2025-09-25 11:39:34 +03:00
Egor c353c69180 Merge pull request #359 from Fr1ngg/bedolaga/fix-unsupported-url-protocol-error-6s98ne
Handle unsupported Happ cryptolink URLs in Telegram
2025-09-25 11:38:34 +03:00
Egor 9c0e489f3b Fix Happ cryptolink button to avoid unsupported URL 2025-09-25 11:38:19 +03:00
Egor d4028a9d9f Merge pull request #358 from Fr1ngg/revert-357-bedolaga/fix-unsupported-url-protocol-error-8xd80s
Revert "Handle Happ cryptolink without unsupported Telegram URL"
2025-09-25 11:37:49 +03:00
Egor 25960c180f Revert "Handle Happ cryptolink without unsupported Telegram URL" 2025-09-25 11:37:40 +03:00
Egor 926bd0cc55 Merge pull request #357 from Fr1ngg/bedolaga/fix-unsupported-url-protocol-error-8xd80s
Handle Happ cryptolink without unsupported Telegram URL
2025-09-25 11:36:45 +03:00
Egor 0d714b4dff Handle Happ cryptolink without unsupported Telegram URL 2025-09-25 11:36:26 +03:00
Egor f5ccc1f103 Merge pull request #356 from Fr1ngg/revert-355-bedolaga/fix-unsupported-url-protocol-error
Revert "Handle non-http Happ subscription links"
2025-09-25 11:36:16 +03:00
Egor 565e6d963b Revert "Handle non-http Happ subscription links" 2025-09-25 11:36:07 +03:00
Egor c057d1e3d7 Merge pull request #355 from Fr1ngg/bedolaga/fix-unsupported-url-protocol-error
Handle non-http Happ subscription links
2025-09-25 11:34:55 +03:00
Egor 10c3151d9e Handle non-http Happ subscription links 2025-09-25 11:34:37 +03:00
Egor cbf88226b4 Merge pull request #354 from Fr1ngg/bedolaga/fix-issues-with-happ-connection-screen-qc79aw
Add Happ cryptolink keyboard with platform buttons
2025-09-25 11:23:22 +03:00
Egor 4ad53ca762 Add Happ cryptolink keyboard and update download options 2025-09-25 11:23:03 +03:00
Egor a2ace90898 Merge pull request #353 from Fr1ngg/revert-352-bedolaga/fix-issues-with-happ-connection-screen
Revert "Add Happ cryptolink keyboard and Mac download option"
2025-09-25 11:22:33 +03:00
Egor 814e38ef59 Revert "Add Happ cryptolink keyboard and Mac download option" 2025-09-25 11:22:23 +03:00
Egor 871e1ae675 Merge pull request #352 from Fr1ngg/bedolaga/fix-issues-with-happ-connection-screen
Add Happ cryptolink keyboard and Mac download option
2025-09-25 11:21:33 +03:00
Egor 67c973124a Add Happ cryptolink keyboard and Mac download option 2025-09-25 11:21:18 +03:00
Egor 42cfc3745d Merge pull request #351 from Fr1ngg/bedolaga/fix-window-issue-in-happ_cryptolink-mode
Add Happ cryptolink subscription translations
2025-09-25 11:09:47 +03:00
Egor ccb4dfa267 Add Happ cryptolink subscription translations 2025-09-25 11:09:31 +03:00
Egor 8a9606db97 Merge pull request #350 from Fr1ngg/revert-343-revert-342-bedolaga/fix-subscription-link-generation-issue
Revert "Revert "Handle Happ cryptolink buttons without unsupported URLs""
2025-09-25 11:04:22 +03:00
Egor c3846a998a Revert "Revert "Handle Happ cryptolink buttons without unsupported URLs"" 2025-09-25 11:04:12 +03:00
Egor 0bcaab3c50 Merge pull request #349 from Fr1ngg/revert-348-bedolaga/fix-subscription-link-generation-issue-nwaeuv
Revert "Add Happ cryptoLink proxy support"
2025-09-25 11:03:58 +03:00
Egor 6dc5150804 Revert "Add Happ cryptoLink proxy support" 2025-09-25 11:03:49 +03:00
Egor 7bba18673a Merge pull request #348 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue-nwaeuv
Add Happ cryptoLink proxy support
2025-09-25 11:02:46 +03:00
Egor de8853bd7c Add Happ cryptoLink proxy support 2025-09-25 11:02:27 +03:00
Egor 6e938b2693 Merge pull request #347 from Fr1ngg/revert-346-bedolaga/fix-subscription-link-generation-issue-xh6pew
Revert "Handle Happ crypto links in subscription flows"
2025-09-25 11:01:50 +03:00
Egor cb41f9263f Revert "Handle Happ crypto links in subscription flows" 2025-09-25 11:01:41 +03:00
Egor fe3f837a58 Merge pull request #346 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue-xh6pew
Handle Happ crypto links in subscription flows
2025-09-25 11:01:09 +03:00
Egor 65374e38b3 Handle Happ crypto links in subscription flows 2025-09-25 11:00:52 +03:00
Egor 24fd4ef842 Merge pull request #345 from Fr1ngg/revert-344-bedolaga/fix-subscription-link-generation-issue-ls1p2r
Revert "Hide Happ cryptolinks behind connect button"
2025-09-25 11:00:34 +03:00
Egor 5682971722 Revert "Hide Happ cryptolinks behind connect button" 2025-09-25 11:00:27 +03:00
Egor c286c44041 Merge pull request #344 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue-ls1p2r
Hide Happ cryptolinks behind connect button
2025-09-25 10:59:21 +03:00
Egor 1dc3013376 Hide Happ cryptolinks behind connect button 2025-09-25 10:59:03 +03:00
Egor ea61734f3a Merge pull request #343 from Fr1ngg/revert-342-bedolaga/fix-subscription-link-generation-issue
Revert "Handle Happ cryptolink buttons without unsupported URLs"
2025-09-25 10:58:50 +03:00
Egor ec521b84e7 Revert "Handle Happ cryptolink buttons without unsupported URLs" 2025-09-25 10:58:24 +03:00
Egor 62189f8642 Merge pull request #342 from Fr1ngg/bedolaga/fix-subscription-link-generation-issue
Handle Happ cryptolink buttons without unsupported URLs
2025-09-25 10:50:13 +03:00
Egor a8e1085884 Handle Happ cryptolink buttons without unsupported URLs 2025-09-25 10:49:54 +03:00
Egor 2bd76d118a Merge pull request #341 from Fr1ngg/bedolaga/add-happ_cryptolink-subscription-mode-a0mn2q
Document happ cryptolink mode in env example
2025-09-25 10:25:23 +03:00
Egor b258bd95bd Document happ cryptolink mode in env example 2025-09-25 10:25:06 +03:00
Egor ab566986c7 Merge pull request #340 from Fr1ngg/revert-339-bedolaga/add-happ_cryptolink-output-mode
Revert "Add Happ crypto link connection mode with download prompts"
2025-09-25 08:51:42 +03:00
Egor 033230f73e Revert "Add Happ crypto link connection mode with download prompts" 2025-09-25 08:51:34 +03:00
Egor 063d77928c Merge pull request #339 from Fr1ngg/bedolaga/add-happ_cryptolink-output-mode
Add Happ crypto link connection mode with download prompts
2025-09-25 08:50:42 +03:00
Egor ff71f4b637 Add Happ crypto link mode and download flow 2025-09-25 08:50:22 +03:00
Egor 88f4b2b2fa Merge pull request #338 from Fr1ngg/revert-337-bedolaga/add-happ_cryptolink-subscription-mode
Revert "Add Happ cryptoLink connect mode with download flow"
2025-09-25 04:33:43 +03:00
Egor b64bda64dc Revert "Add Happ cryptoLink connect mode with download flow" 2025-09-25 04:33:28 +03:00
Egor dfeea949dd Merge pull request #337 from Fr1ngg/bedolaga/add-happ_cryptolink-subscription-mode
Add Happ cryptoLink connect mode with download flow
2025-09-25 04:32:05 +03:00
Egor 54d798f358 Add Happ cryptoLink mode and download support 2025-09-25 04:31:44 +03:00
Egor 4546450587 Merge pull request #336 from Fr1ngg/revert-333-bedolaga/add-happ_cryptolink-subscription-output-mode
Revert "feat: add Happ crypto link mode and download flow"
2025-09-25 04:12:53 +03:00
Egor ec6b6e6b09 Revert "feat: add Happ crypto link mode and download flow" 2025-09-25 04:12:42 +03:00
Egor cb5d4df4f0 Merge pull request #335 from Fr1ngg/revert-334-bedolaga/implement-universal-migration-for-subscriptions
Revert "Add happ_crypto_link column handling to universal migration"
2025-09-25 04:12:34 +03:00
Egor 1a060759f9 Revert "Add happ_crypto_link column handling to universal migration" 2025-09-25 04:12:24 +03:00
Egor 44fc2fd771 Merge pull request #334 from Fr1ngg/bedolaga/implement-universal-migration-for-subscriptions
Add happ_crypto_link column handling to universal migration
2025-09-25 04:09:09 +03:00
Egor 92dbf3269b Add happ_crypto_link column handling to universal migration 2025-09-25 04:08:49 +03:00
Egor c6b4de05f3 Merge pull request #333 from Fr1ngg/bedolaga/add-happ_cryptolink-subscription-output-mode
feat: add Happ crypto link mode and download flow
2025-09-25 04:00:07 +03:00
Egor ae3c34c2fe chore: tidy migration imports 2025-09-25 03:59:03 +03:00
104 changed files with 11320 additions and 618 deletions
+17
View File
@@ -89,8 +89,13 @@ MAX_DEVICES_LIMIT=15
# Дефолт параметры для подписок выданных через админку
DEFAULT_TRAFFIC_LIMIT_GB=100
# Плюсовать дни триала при покупке платной подписке
TRIAL_ADD_REMAINING_DAYS_TO_PAID=false
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# Сбрасывать трафик при каждой оплате подписки
RESET_TRAFFIC_ON_PAYMENT=false
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
@@ -280,11 +285,21 @@ HIDE_SUBSCRIPTION_LINK=false
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - Открывает ссылку напрямую в браузере (режим 4)
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
HAPP_DOWNLOAD_LINK_IOS=
HAPP_DOWNLOAD_LINK_ANDROID=
HAPP_DOWNLOAD_LINK_MACOS=
HAPP_DOWNLOAD_LINK_WINDOWS=
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
# Пропустить принятие правил использования бота
SKIP_RULES_ACCEPT=false
# Пропустить запрос реферального кода
@@ -328,6 +343,8 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.3.9-$(git rev-parse --short HEAD)"
VERSION="v2.4.1-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.3.9-dev-$(git rev-parse --short HEAD)"
VERSION="v2.4.1-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.3.9-pr-$(git rev-parse --short HEAD)"
VERSION="v2.4.1-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.3.9-$(git rev-parse --short HEAD)"
VERSION="v2.4.1-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.3.9-dev-$(git rev-parse --short HEAD)"
VERSION="v2.4.1-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.3.9-pr-$(git rev-parse --short HEAD)"
VERSION="v2.4.1-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+2
View File
@@ -8,6 +8,8 @@
!app-config.json
!main.py
!requirements.txt
!docs/
!docs/**
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
+2 -2
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.3.9"
ARG VERSION="v2.4.1"
ARG BUILD_DATE
ARG VCS_REF
@@ -45,7 +45,7 @@ ENV PYTHONPATH=/app \
BUILD_DATE=${BUILD_DATE} \
VCS_REF=${VCS_REF}
EXPOSE 8081 8082 8083
EXPOSE 8081 8082
LABEL org.opencontainers.image.title="Bedolaga RemnaWave Bot" \
org.opencontainers.image.description="Telegram bot for RemnaWave VPN service" \
+23 -6
View File
@@ -108,6 +108,10 @@ docker compose logs
| 🛡️ **REMNAWAVE_SECRET_KEY** | Ключ защиты панели | `secret_name:secret_value` |
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
### 🌐 Интеграция веб-админки
Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md).
### 📊 Статус серверов в главном меню
| Переменная | Описание | Пример |
@@ -254,6 +258,11 @@ ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
# Автоматические отчеты
ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
ADMIN_REPORTS_TOPIC_ID= # ID топика для отчетов
ADMIN_REPORTS_SEND_TIME=10:00 # Время отправки (по МСК) ежедневного отчета
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
@@ -326,6 +335,8 @@ DEFAULT_TRAFFIC_LIMIT_GB=100
# ===== ГЛОБАЛЬНЫЙ ПАРАМЕТР ДЛЯ ВСЕХ ПОДПИСОК =====
DEFAULT_TRAFFIC_RESET_STRATEGY=MONTH
# Сбрасывать трафик при каждой оплате подписки
RESET_TRAFFIC_ON_PAYMENT=false
# ===== НАСТРОЙКИ ТРАФИКА =====
# Режим выбора трафика:
@@ -501,12 +512,6 @@ PAL24_MIN_AMOUNT_KOPEKS=10000
PAL24_MAX_AMOUNT_KOPEKS=100000000
PAL24_REQUEST_TIMEOUT=30
# Настройки PayPalych
1. Включите интеграцию (`PAL24_ENABLED=true`) и укажите `PAL24_API_TOKEN`, `PAL24_SHOP_ID`, а также `PAL24_SIGNATURE_TOKEN` для проверки подписи уведомлений.
2. Настройте в кабинете PayPalych **Result URL** и success/fail redirect на `https://<ваш-домен>/pal24-webhook`.
3. Убедитесь, что порт `PAL24_WEBHOOK_PORT` (по умолчанию `8084`) проброшен через прокси/фаервол.
4. Для теста можно отправить postback вручную (пример команды см. ниже в разделе «Проверка PayPalych postback»).
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)
@@ -521,11 +526,21 @@ HIDE_SUBSCRIPTION_LINK=false
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - Открывает ссылку напрямую в браузере (режим 4)
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
# Параметры режима happ_cryptolink
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false
HAPP_DOWNLOAD_LINK_IOS=
HAPP_DOWNLOAD_LINK_ANDROID=
HAPP_DOWNLOAD_LINK_MACOS=
HAPP_DOWNLOAD_LINK_WINDOWS=
# Кнопка (Подключится) с редиректом (тк ссылки с happ:// тг не поддерживает) - Без установленной ссылки на редирект кнопки (подключится) не будет! Пример: https://sub.domain.sub/redirect-page/?redirect_to=
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE=
# Пропустить принятие правил использования бота
SKIP_RULES_ACCEPT=false
# Пропустить запрос реферального кода
@@ -569,6 +584,8 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# ===== ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ =====
# Конфигурация приложений для гайда подключения
+2
View File
@@ -39,6 +39,7 @@ from app.handlers.admin import (
welcome_text as admin_welcome_text,
tickets as admin_tickets,
reports as admin_reports,
bot_configuration as admin_bot_configuration,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -141,6 +142,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_welcome_text.register_welcome_text_handlers(dp)
admin_tickets.register_handlers(dp)
admin_reports.register_handlers(dp)
admin_bot_configuration.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
+84 -5
View File
@@ -53,8 +53,8 @@ class Settings(BaseSettings):
REDIS_URL: str = "redis://localhost:6379/0"
REMNAWAVE_API_URL: str
REMNAWAVE_API_KEY: str
REMNAWAVE_API_URL: str = ""
REMNAWAVE_API_KEY: str = ""
REMNAWAVE_SECRET_KEY: Optional[str] = None
REMNAWAVE_USERNAME: Optional[str] = None
@@ -66,10 +66,12 @@ class Settings(BaseSettings):
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
TRIAL_DEVICE_LIMIT: int = 2
TRIAL_ADD_REMAINING_DAYS_TO_PAID: bool = False
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
DEFAULT_DEVICE_LIMIT: int = 1
TRIAL_SQUAD_UUID: str
TRIAL_SQUAD_UUID: str = ""
DEFAULT_TRAFFIC_RESET_STRATEGY: str = "MONTH"
RESET_TRAFFIC_ON_PAYMENT: bool = False
MAX_DEVICES_LIMIT: int = 20
TRIAL_WARNING_HOURS: int = 2
@@ -209,6 +211,13 @@ class Settings(BaseSettings):
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False
HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None
HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None
HAPP_DOWNLOAD_LINK_ANDROID: Optional[str] = None
HAPP_DOWNLOAD_LINK_MACOS: Optional[str] = None
HAPP_DOWNLOAD_LINK_WINDOWS: Optional[str] = None
HAPP_DOWNLOAD_LINK_PC: Optional[str] = None
HIDE_SUBSCRIPTION_LINK: bool = False
ENABLE_LOGO_MODE: bool = True
LOGO_FILE: str = "vpn_logo.png"
@@ -217,6 +226,7 @@ class Settings(BaseSettings):
DEFAULT_LANGUAGE: str = "ru"
AVAILABLE_LANGUAGES: str = "ru,en"
LANGUAGE_SELECTION_ENABLED: bool = True
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/bot.log"
@@ -224,6 +234,19 @@ class Settings(BaseSettings):
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
WEB_API_ENABLED: bool = False
WEB_API_HOST: str = "0.0.0.0"
WEB_API_PORT: int = 8080
WEB_API_WORKERS: int = 1
WEB_API_ALLOWED_ORIGINS: str = "*"
WEB_API_DOCS_ENABLED: bool = False
WEB_API_TITLE: str = "Remnawave Bot Admin API"
WEB_API_VERSION: str = "1.0.0"
WEB_API_DEFAULT_TOKEN: Optional[str] = None
WEB_API_DEFAULT_TOKEN_NAME: str = "Bootstrap Token"
WEB_API_TOKEN_HASH_ALGORITHM: str = "sha256"
WEB_API_REQUEST_LOGGING: bool = True
APP_CONFIG_PATH: str = "app-config.json"
ENABLE_DEEP_LINKS: bool = True
@@ -430,7 +453,10 @@ class Settings(BaseSettings):
return ["ru", "en"]
except AttributeError:
return ["ru", "en"]
def is_language_selection_enabled(self) -> bool:
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
def format_price(self, price_kopeks: int) -> str:
rubles = price_kopeks // 100
return f"{rubles}"
@@ -543,6 +569,41 @@ class Settings(BaseSettings):
def get_cryptobot_invoice_expires_seconds(self) -> int:
return self.CRYPTOBOT_INVOICE_EXPIRES_HOURS * 3600
def is_happ_cryptolink_mode(self) -> bool:
return self.CONNECT_BUTTON_MODE == "happ_cryptolink"
def is_happ_download_button_enabled(self) -> bool:
return self.is_happ_cryptolink_mode() and self.CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED
def should_hide_subscription_link(self) -> bool:
"""Returns True when subscription links must be hidden from the interface."""
if self.is_happ_cryptolink_mode():
return False
return self.HIDE_SUBSCRIPTION_LINK
def get_happ_cryptolink_redirect_template(self) -> Optional[str]:
template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip()
return template or None
def get_happ_download_link(self, platform: str) -> Optional[str]:
platform_key = platform.lower()
if platform_key == "pc":
platform_key = "windows"
links = {
"ios": (self.HAPP_DOWNLOAD_LINK_IOS or "").strip(),
"android": (self.HAPP_DOWNLOAD_LINK_ANDROID or "").strip(),
"macos": (self.HAPP_DOWNLOAD_LINK_MACOS or "").strip(),
"windows": (
(self.HAPP_DOWNLOAD_LINK_WINDOWS or "").strip()
or (self.HAPP_DOWNLOAD_LINK_PC or "").strip()
),
}
link = links.get(platform_key)
return link if link else None
def is_maintenance_mode(self) -> bool:
return self.MAINTENANCE_MODE
@@ -919,7 +980,25 @@ class Settings(BaseSettings):
def get_server_status_request_timeout(self) -> int:
return max(1, self.SERVER_STATUS_REQUEST_TIMEOUT)
def is_web_api_enabled(self) -> bool:
return bool(self.WEB_API_ENABLED)
def get_web_api_allowed_origins(self) -> list[str]:
raw = (self.WEB_API_ALLOWED_ORIGINS or "").split(",")
origins = [origin.strip() for origin in raw if origin.strip()]
return origins or ["*"]
def get_web_api_docs_config(self) -> Dict[str, Optional[str]]:
if self.WEB_API_DOCS_ENABLED:
return {
"docs_url": "/docs",
"redoc_url": "/redoc",
"openapi_url": "/openapi.json",
}
return {"docs_url": None, "redoc_url": None, "openapi_url": None}
def get_support_system_mode(self) -> str:
mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower()
return mode if mode in {"tickets", "contact", "both"} else "both"
+6 -2
View File
@@ -32,6 +32,7 @@ async def create_campaign(
subscription_traffic_gb: Optional[int] = None,
subscription_device_limit: Optional[int] = None,
subscription_squads: Optional[List[str]] = None,
is_active: bool = True,
) -> AdvertisingCampaign:
campaign = AdvertisingCampaign(
name=name,
@@ -43,7 +44,7 @@ async def create_campaign(
subscription_device_limit=subscription_device_limit,
subscription_squads=subscription_squads or [],
created_by=created_by,
is_active=True,
is_active=is_active,
)
db.add(campaign)
@@ -135,7 +136,10 @@ async def update_campaign(
"is_active",
}
update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
update_data = {}
for key, value in kwargs.items():
if key in allowed_fields and value is not None:
update_data[key] = value
if not update_data:
return campaign
+70 -3
View File
@@ -30,13 +30,23 @@ logger = logging.getLogger(__name__)
async def get_promo_groups_with_counts(
db: AsyncSession,
*,
offset: int = 0,
limit: Optional[int] = None,
) -> List[Tuple[PromoGroup, int]]:
result = await db.execute(
query = (
select(PromoGroup, func.count(User.id))
.outerjoin(User, User.promo_group_id == PromoGroup.id)
.group_by(PromoGroup.id)
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
)
if offset:
query = query.offset(offset)
if limit is not None:
query = query.limit(limit)
result = await db.execute(query)
return result.all()
@@ -44,6 +54,11 @@ async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[Pro
return await db.get(PromoGroup, group_id)
async def count_promo_groups(db: AsyncSession) -> int:
result = await db.execute(select(func.count(PromoGroup.id)))
return int(result.scalar_one())
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
result = await db.execute(
select(PromoGroup).where(PromoGroup.is_default.is_(True))
@@ -60,6 +75,8 @@ async def create_promo_group(
device_discount_percent: int,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: bool = True,
is_default: bool = False,
) -> PromoGroup:
normalized_period_discounts = _normalize_period_discounts(period_discounts)
@@ -69,6 +86,9 @@ async def create_promo_group(
else None
)
existing_default = await get_default_promo_group(db)
should_be_default = existing_default is None or is_default
promo_group = PromoGroup(
name=name.strip(),
server_discount_percent=max(0, min(100, server_discount_percent)),
@@ -76,21 +96,33 @@ async def create_promo_group(
device_discount_percent=max(0, min(100, device_discount_percent)),
period_discounts=normalized_period_discounts or None,
auto_assign_total_spent_kopeks=auto_assign_total_spent_kopeks,
is_default=False,
apply_discounts_to_addons=bool(apply_discounts_to_addons),
is_default=should_be_default,
)
db.add(promo_group)
await db.flush()
if should_be_default and existing_default and existing_default.id != promo_group.id:
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != promo_group.id)
.values(is_default=False)
)
await db.commit()
await db.refresh(promo_group)
logger.info(
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s",
"Создана промогруппа '%s' (default=%s) с скидками (servers=%s%%, traffic=%s%%, devices=%s%%, periods=%s) и порогом автоприсвоения %s, скидки на доп. услуги: %s",
promo_group.name,
promo_group.is_default,
promo_group.server_discount_percent,
promo_group.traffic_discount_percent,
promo_group.device_discount_percent,
normalized_period_discounts,
(auto_assign_total_spent_kopeks or 0) / 100,
"on" if promo_group.apply_discounts_to_addons else "off",
)
return promo_group
@@ -106,6 +138,8 @@ async def update_promo_group(
device_discount_percent: Optional[int] = None,
period_discounts: Optional[Dict[int, int]] = None,
auto_assign_total_spent_kopeks: Optional[int] = None,
apply_discounts_to_addons: Optional[bool] = None,
is_default: Optional[bool] = None,
) -> PromoGroup:
if name is not None:
group.name = name.strip()
@@ -120,6 +154,38 @@ async def update_promo_group(
group.period_discounts = normalized_period_discounts or None
if auto_assign_total_spent_kopeks is not None:
group.auto_assign_total_spent_kopeks = max(0, auto_assign_total_spent_kopeks)
if apply_discounts_to_addons is not None:
group.apply_discounts_to_addons = bool(apply_discounts_to_addons)
if is_default is not None:
if is_default:
group.is_default = True
await db.flush()
await db.execute(
update(PromoGroup)
.where(PromoGroup.id != group.id)
.values(is_default=False)
)
else:
if group.is_default:
group.is_default = False
await db.flush()
replacement = await db.execute(
select(PromoGroup)
.where(PromoGroup.id != group.id)
.order_by(PromoGroup.id)
.limit(1)
)
new_default = replacement.scalars().first()
if new_default:
await db.execute(
update(PromoGroup)
.where(PromoGroup.id == new_default.id)
.values(is_default=True)
)
else:
# Не допускаем состояния без базовой промогруппы
group.is_default = True
await db.commit()
await db.refresh(group)
@@ -142,6 +208,7 @@ async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
logger.error("Не найдена базовая промогруппа для reassignment")
return False
await db.execute(
update(User)
.where(User.promo_group_id == group.id)
+129 -12
View File
@@ -1,11 +1,22 @@
import logging
from datetime import datetime
from typing import Iterable, List, Optional, Sequence, Tuple
from sqlalchemy import select, and_, func, update, delete, text
from sqlalchemy import (
select,
and_,
func,
update,
delete,
text,
or_,
cast,
String,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription
from app.database.models import PromoGroup, ServerSquad, SubscriptionServer, Subscription, User
logger = logging.getLogger(__name__)
@@ -232,10 +243,10 @@ async def sync_with_remnawave(
db: AsyncSession,
remnawave_squads: List[dict]
) -> Tuple[int, int, int]:
created = 0
updated = 0
disabled = 0
removed = 0
existing_servers = {}
result = await db.execute(select(ServerSquad))
@@ -265,15 +276,121 @@ async def sync_with_remnawave(
)
created += 1
for uuid, server in existing_servers.items():
if uuid not in remnawave_uuids and server.is_available:
server.is_available = False
disabled += 1
removed_servers = [
server for uuid, server in existing_servers.items()
if uuid not in remnawave_uuids
]
if removed_servers:
removed_ids = [server.id for server in removed_servers]
removed_uuids = {server.squad_uuid for server in removed_servers}
subscription_ids_result = await db.execute(
select(SubscriptionServer.subscription_id)
.where(SubscriptionServer.server_squad_id.in_(removed_ids))
)
subscription_ids = {row[0] for row in subscription_ids_result.fetchall()}
for server in removed_servers:
logger.info(
"🗑️ Удаляется сервер %s (UUID: %s)",
server.display_name,
server.squad_uuid,
)
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.server_squad_id.in_(removed_ids))
)
subscriptions_to_update: dict[int, Subscription] = {}
if subscription_ids:
subscriptions_result = await db.execute(
select(Subscription).where(Subscription.id.in_(subscription_ids))
)
for subscription in subscriptions_result.scalars().unique().all():
subscriptions_to_update[subscription.id] = subscription
for squad_uuid in removed_uuids:
if not squad_uuid:
continue
extra_result = await db.execute(
select(Subscription).where(
text("connected_squads::text LIKE :uuid_pattern")
),
{"uuid_pattern": f'%"{squad_uuid}"%'}
)
for subscription in extra_result.scalars().unique().all():
subscriptions_to_update[subscription.id] = subscription
cleaned_subscriptions = 0
for subscription in subscriptions_to_update.values():
current_squads = list(subscription.connected_squads or [])
if not current_squads:
continue
filtered_squads = [
squad_uuid for squad_uuid in current_squads if squad_uuid not in removed_uuids
]
if len(filtered_squads) != len(current_squads):
subscription.connected_squads = filtered_squads
subscription.updated_at = datetime.utcnow()
cleaned_subscriptions += 1
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
removed = len(removed_servers)
if cleaned_subscriptions:
logger.info(
"🧹 Обновлены подписки после удаления серверов: %s",
cleaned_subscriptions,
)
await db.commit()
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{disabled}")
return created, updated, disabled
logger.info(f"🔄 Синхронизация завершена: +{created} ~{updated} -{removed}")
return created, updated, removed
async def get_server_connected_users(
db: AsyncSession,
server_id: int
) -> List[User]:
server_uuid_result = await db.execute(
select(ServerSquad.squad_uuid).where(ServerSquad.id == server_id)
)
server_uuid = server_uuid_result.scalar_one_or_none()
connection_filters = [SubscriptionServer.id.isnot(None)]
if server_uuid:
connection_filters.append(
cast(Subscription.connected_squads, String).like(
f'%"{server_uuid}"%'
)
)
result = await db.execute(
select(User)
.join(Subscription, Subscription.user_id == User.id)
.outerjoin(
SubscriptionServer,
and_(
SubscriptionServer.subscription_id == Subscription.id,
SubscriptionServer.server_squad_id == server_id,
),
)
.where(or_(*connection_filters))
.options(selectinload(User.subscription))
.order_by(User.id)
)
return result.scalars().unique().all()
def _generate_display_name(original_name: str) -> str:
+27 -5
View File
@@ -116,13 +116,33 @@ async def extend_subscription(
else:
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
if subscription.is_trial:
start_date = subscription.start_date or current_time
total_duration = subscription.end_date - start_date
max_trial_duration = timedelta(days=settings.TRIAL_DURATION_DAYS)
if total_duration > max_trial_duration:
subscription.is_trial = False
logger.info(
"🎯 Подписка %s автоматически переведена из триальной в платную после продления"
", итоговая длительность: %s дней",
subscription.id,
total_duration.days,
)
if subscription.user:
subscription.user.has_had_paid_subscription = True
if subscription.status == SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
await clear_notifications(db, subscription.id)
@@ -965,7 +985,8 @@ async def create_subscription(
device_limit: int = 1,
connected_squads: list = None,
remnawave_short_uuid: str = None,
subscription_url: str = ""
subscription_url: str = "",
subscription_crypto_link: str = ""
) -> Subscription:
if end_date is None:
@@ -984,7 +1005,8 @@ async def create_subscription(
device_limit=device_limit,
connected_squads=connected_squads,
remnawave_short_uuid=remnawave_short_uuid,
subscription_url=subscription_url
subscription_url=subscription_url,
subscription_crypto_link=subscription_crypto_link
)
db.add(subscription)
+40
View File
@@ -0,0 +1,40 @@
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SystemSetting
async def upsert_system_setting(
db: AsyncSession,
key: str,
value: Optional[str],
description: Optional[str] = None,
) -> SystemSetting:
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == key)
)
setting = result.scalar_one_or_none()
if setting is None:
setting = SystemSetting(key=key, value=value, description=description)
db.add(setting)
else:
setting.value = value
if description is not None:
setting.description = description
await db.flush()
return setting
async def delete_system_setting(db: AsyncSession, key: str) -> None:
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == key)
)
setting = result.scalar_one_or_none()
if setting is not None:
await db.delete(setting)
await db.flush()
+1 -6
View File
@@ -118,12 +118,7 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
and_(
Transaction.user_id == user_id,
Transaction.is_completed.is_(True),
Transaction.type.in_(
[
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
]
),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
)
)
)
+38 -8
View File
@@ -7,9 +7,18 @@ from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import User, UserStatus, Subscription, Transaction, PromoGroup
from app.database.models import (
User,
UserStatus,
Subscription,
Transaction,
PromoGroup,
PaymentMethod,
TransactionType,
)
from app.config import settings
from app.database.crud.promo_group import get_default_promo_group
from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
@@ -104,11 +113,13 @@ async def create_user(
promo_group_id = default_group.id
safe_first = sanitize_telegram_name(first_name)
safe_last = sanitize_telegram_name(last_name)
user = User(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
first_name=safe_first,
last_name=safe_last,
language=language,
referred_by_id=referred_by_id,
referral_code=referral_code,
@@ -136,7 +147,10 @@ async def update_user(
**kwargs
) -> User:
from app.utils.validators import sanitize_telegram_name
for field, value in kwargs.items():
if field in ("first_name", "last_name"):
value = sanitize_telegram_name(value)
if hasattr(user, field):
setattr(user, field, value)
@@ -205,10 +219,12 @@ async def add_user_balance_by_id(
async def subtract_user_balance(
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str
db: AsyncSession,
user: User,
amount_kopeks: int,
description: str,
create_transaction: bool = False,
payment_method: Optional[PaymentMethod] = None,
) -> bool:
logger.error(f"💸 ОТЛАДКА subtract_user_balance:")
logger.error(f" 👤 User ID: {user.id} (TG: {user.telegram_id})")
@@ -227,7 +243,21 @@ async def subtract_user_balance(
await db.commit()
await db.refresh(user)
if create_transaction:
from app.database.crud.transaction import (
create_transaction as create_trans,
)
await create_trans(
db=db,
user_id=user.id,
type=TransactionType.WITHDRAWAL,
amount_kopeks=amount_kopeks,
description=description,
payment_method=payment_method,
)
logger.error(f" ✅ Средства списаны: {old_balance}{user.balance_kopeks}")
return True
+106
View File
@@ -0,0 +1,106 @@
"""CRUD операции для токенов административного веб-API."""
from __future__ import annotations
from datetime import datetime
from typing import Iterable, List, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import WebApiToken
async def list_tokens(
db: AsyncSession,
*,
include_inactive: bool = False,
) -> List[WebApiToken]:
query = select(WebApiToken)
if not include_inactive:
query = query.where(WebApiToken.is_active.is_(True))
query = query.order_by(WebApiToken.created_at.desc())
result = await db.execute(query)
return list(result.scalars().all())
async def get_token_by_id(db: AsyncSession, token_id: int) -> Optional[WebApiToken]:
return await db.get(WebApiToken, token_id)
async def get_token_by_hash(db: AsyncSession, token_hash: str) -> Optional[WebApiToken]:
query = select(WebApiToken).where(
WebApiToken.token_hash == token_hash
)
result = await db.execute(query)
return result.scalar_one_or_none()
async def create_token(
db: AsyncSession,
*,
name: str,
token_hash: str,
token_prefix: str,
description: Optional[str] = None,
expires_at: Optional[datetime] = None,
created_by: Optional[str] = None,
) -> WebApiToken:
token = WebApiToken(
name=name,
token_hash=token_hash,
token_prefix=token_prefix,
description=description,
expires_at=expires_at,
created_by=created_by,
is_active=True,
)
db.add(token)
await db.flush()
await db.refresh(token)
return token
async def update_token(
db: AsyncSession,
token: WebApiToken,
**kwargs,
) -> WebApiToken:
for key, value in kwargs.items():
if hasattr(token, key):
setattr(token, key, value)
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
async def set_tokens_active_status(
db: AsyncSession,
token_ids: Iterable[int],
*,
is_active: bool,
) -> None:
await db.execute(
update(WebApiToken)
.where(WebApiToken.id.in_(list(token_ids)))
.values(is_active=is_active, updated_at=datetime.utcnow())
)
async def delete_token(db: AsyncSession, token: WebApiToken) -> None:
await db.delete(token)
__all__ = [
"list_tokens",
"get_token_by_id",
"get_token_by_hash",
"create_token",
"update_token",
"set_tokens_active_status",
"delete_token",
]
+2 -2
View File
@@ -10,7 +10,7 @@ from app.database.models import Base
logger = logging.getLogger(__name__)
engine = create_async_engine(
settings.DATABASE_URL,
settings.get_database_url(),
poolclass=NullPool,
echo=settings.DEBUG,
future=True
@@ -48,4 +48,4 @@ async def init_db():
async def close_db():
await engine.dispose()
logger.info("✅ Подключение к базе данных закрыто")
logger.info("✅ Подключение к базе данных закрыто")
+28 -4
View File
@@ -292,6 +292,7 @@ class PromoGroup(Base):
device_discount_percent = Column(Integer, nullable=False, default=0)
period_discounts = Column(JSON, nullable=True, default=dict)
auto_assign_total_spent_kopeks = Column(Integer, nullable=True, default=None)
apply_discounts_to_addons = Column(Boolean, nullable=False, default=True)
is_default = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
@@ -387,6 +388,7 @@ class User(Base):
discount_offers = relationship("DiscountOffer", back_populates="user")
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
last_remnawave_sync = Column(DateTime, nullable=True)
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
@@ -435,7 +437,8 @@ class Subscription(Base):
traffic_used_gb = Column(Float, default=0.0)
subscription_url = Column(String, nullable=True)
subscription_crypto_link = Column(String, nullable=True)
device_limit = Column(Integer, default=1)
connected_squads = Column(JSON, default=list)
@@ -1088,7 +1091,7 @@ class Ticket(Base):
class TicketMessage(Base):
__tablename__ = "ticket_messages"
id = Column(Integer, primary_key=True, index=True)
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
@@ -1115,6 +1118,27 @@ class TicketMessage(Base):
@property
def is_admin_message(self) -> bool:
return self.is_from_admin
def __repr__(self):
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
class WebApiToken(Base):
__tablename__ = "web_api_tokens"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
token_hash = Column(String(128), nullable=False, unique=True, index=True)
token_prefix = Column(String(32), nullable=False, index=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
expires_at = Column(DateTime, nullable=True)
last_used_at = Column(DateTime, nullable=True)
last_used_ip = Column(String(64), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_by = Column(String(255), nullable=True)
def __repr__(self) -> str:
status = "active" if self.is_active else "revoked"
return f"<WebApiToken id={self.id} name='{self.name}' status={status}>"
+363 -8
View File
@@ -1,7 +1,13 @@
import logging
from sqlalchemy import text, inspect
from datetime import datetime
from sqlalchemy import inspect, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import engine
from app.config import settings
from app.database.database import AsyncSessionLocal, engine
from app.database.models import WebApiToken
from app.utils.security import hash_api_token
logger = logging.getLogger(__name__)
@@ -931,6 +937,55 @@ async def ensure_promo_groups_setup():
"Добавлена колонка promo_groups.auto_assign_total_spent_kopeks"
)
addon_discount_column_exists = await check_column_exists(
"promo_groups", "apply_discounts_to_addons"
)
if not addon_discount_column_exists:
if db_type == "sqlite":
await conn.execute(
text(
"ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT 1"
)
)
await conn.execute(
text(
"UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL"
)
)
elif db_type == "postgresql":
await conn.execute(
text(
"ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons BOOLEAN NOT NULL DEFAULT TRUE"
)
)
await conn.execute(
text(
"UPDATE promo_groups SET apply_discounts_to_addons = TRUE WHERE apply_discounts_to_addons IS NULL"
)
)
elif db_type == "mysql":
await conn.execute(
text(
"ALTER TABLE promo_groups ADD COLUMN apply_discounts_to_addons TINYINT(1) NOT NULL DEFAULT 1"
)
)
await conn.execute(
text(
"UPDATE promo_groups SET apply_discounts_to_addons = 1 WHERE apply_discounts_to_addons IS NULL"
)
)
else:
logger.error(
f"Неподдерживаемый тип БД для promo_groups.apply_discounts_to_addons: {db_type}"
)
return False
logger.info(
"Добавлена колонка promo_groups.apply_discounts_to_addons"
)
addon_discount_column_exists = True
column_exists = await check_column_exists("users", "promo_group_id")
if not column_exists:
@@ -977,6 +1032,39 @@ async def ensure_promo_groups_setup():
logger.info("Добавлена колонка users.auto_promo_group_assigned")
threshold_column_exists = await check_column_exists(
"users", "auto_promo_group_threshold_kopeks"
)
if not threshold_column_exists:
if db_type == "sqlite":
await conn.execute(
text(
"ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks INTEGER NOT NULL DEFAULT 0"
)
)
elif db_type == "postgresql":
await conn.execute(
text(
"ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks BIGINT NOT NULL DEFAULT 0"
)
)
elif db_type == "mysql":
await conn.execute(
text(
"ALTER TABLE users ADD COLUMN auto_promo_group_threshold_kopeks BIGINT NOT NULL DEFAULT 0"
)
)
else:
logger.error(
f"Неподдерживаемый тип БД для users.auto_promo_group_threshold_kopeks: {db_type}"
)
return False
logger.info(
"Добавлена колонка users.auto_promo_group_threshold_kopeks"
)
index_exists = await check_index_exists("users", "ix_users_promo_group_id")
if not index_exists:
@@ -1029,9 +1117,25 @@ async def ensure_promo_groups_setup():
if existing_default:
default_group_id = existing_default[0]
else:
await conn.execute(
text(
"""
insert_params = {
"name": default_group_name,
"is_default": True,
}
if addon_discount_column_exists:
insert_sql = """
INSERT INTO promo_groups (
name,
server_discount_percent,
traffic_discount_percent,
device_discount_percent,
apply_discounts_to_addons,
is_default
) VALUES (:name, 0, 0, 0, :apply_discounts_to_addons, :is_default)
"""
insert_params["apply_discounts_to_addons"] = True
else:
insert_sql = """
INSERT INTO promo_groups (
name,
server_discount_percent,
@@ -1040,9 +1144,8 @@ async def ensure_promo_groups_setup():
is_default
) VALUES (:name, 0, 0, 0, :is_default)
"""
),
{"name": default_group_name, "is_default": True},
)
await conn.execute(text(insert_sql), insert_params)
result = await conn.execute(
text(
@@ -1366,6 +1469,39 @@ async def add_ticket_sla_columns():
logger.error(f"Ошибка добавления SLA колонки в tickets: {e}")
return False
async def add_subscription_crypto_link_column() -> bool:
column_exists = await check_column_exists('subscriptions', 'subscription_crypto_link')
if column_exists:
logger.info("️ Колонка subscription_crypto_link уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link TEXT"))
elif db_type == 'postgresql':
await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link VARCHAR"))
elif db_type == 'mysql':
await conn.execute(text("ALTER TABLE subscriptions ADD COLUMN subscription_crypto_link VARCHAR(512)"))
else:
logger.error(f"Неподдерживаемый тип БД для добавления subscription_crypto_link: {db_type}")
return False
await conn.execute(text(
"UPDATE subscriptions SET subscription_crypto_link = subscription_url "
"WHERE subscription_crypto_link IS NULL OR subscription_crypto_link = ''"
))
logger.info("✅ Добавлена колонка subscription_crypto_link в таблицу subscriptions")
return True
except Exception as e:
logger.error(f"Ошибка добавления колонки subscription_crypto_link: {e}")
return False
async def fix_foreign_keys_for_user_deletion():
try:
async with engine.begin() as conn:
@@ -1719,6 +1855,188 @@ async def ensure_server_promo_groups_setup() -> bool:
)
return False
async def create_system_settings_table() -> bool:
table_exists = await check_table_exists("system_settings")
if table_exists:
logger.info("️ Таблица system_settings уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE system_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key VARCHAR(255) NOT NULL UNIQUE,
value TEXT NULL,
description TEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
"""
elif db_type == "postgresql":
create_sql = """
CREATE TABLE system_settings (
id SERIAL PRIMARY KEY,
key VARCHAR(255) NOT NULL UNIQUE,
value TEXT NULL,
description TEXT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
"""
else:
create_sql = """
CREATE TABLE system_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
key VARCHAR(255) NOT NULL UNIQUE,
value TEXT NULL,
description TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
await conn.execute(text(create_sql))
logger.info("✅ Таблица system_settings создана")
return True
except Exception as error:
logger.error(f"Ошибка создания таблицы system_settings: {error}")
return False
async def create_web_api_tokens_table() -> bool:
table_exists = await check_table_exists("web_api_tokens")
if table_exists:
logger.info("️ Таблица web_api_tokens уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE web_api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
token_hash VARCHAR(128) NOT NULL UNIQUE,
token_prefix VARCHAR(32) NOT NULL,
description TEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NULL,
last_used_at DATETIME NULL,
last_used_ip VARCHAR(64) NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
created_by VARCHAR(255) NULL
);
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
"""
elif db_type == "postgresql":
create_sql = """
CREATE TABLE web_api_tokens (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
token_hash VARCHAR(128) NOT NULL UNIQUE,
token_prefix VARCHAR(32) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NULL,
last_used_at TIMESTAMP NULL,
last_used_ip VARCHAR(64) NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by VARCHAR(255) NULL
);
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
"""
else:
create_sql = """
CREATE TABLE web_api_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
token_hash VARCHAR(128) NOT NULL UNIQUE,
token_prefix VARCHAR(32) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
expires_at TIMESTAMP NULL,
last_used_at TIMESTAMP NULL,
last_used_ip VARCHAR(64) NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by VARCHAR(255) NULL
) ENGINE=InnoDB;
CREATE INDEX idx_web_api_tokens_active ON web_api_tokens(is_active);
CREATE INDEX idx_web_api_tokens_prefix ON web_api_tokens(token_prefix);
CREATE INDEX idx_web_api_tokens_last_used ON web_api_tokens(last_used_at);
"""
await conn.execute(text(create_sql))
logger.info("✅ Таблица web_api_tokens создана")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания таблицы web_api_tokens: {error}")
return False
async def ensure_default_web_api_token() -> bool:
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
if not default_token:
return True
token_name = (settings.WEB_API_DEFAULT_TOKEN_NAME or "Bootstrap Token").strip()
try:
async with AsyncSessionLocal() as session:
token_hash = hash_api_token(default_token, settings.WEB_API_TOKEN_HASH_ALGORITHM)
result = await session.execute(
select(WebApiToken).where(WebApiToken.token_hash == token_hash)
)
existing = result.scalar_one_or_none()
if existing:
updated = False
if not existing.is_active:
existing.is_active = True
updated = True
if token_name and existing.name != token_name:
existing.name = token_name
updated = True
if updated:
existing.updated_at = datetime.utcnow()
await session.commit()
return True
token = WebApiToken(
name=token_name or "Bootstrap Token",
token_hash=token_hash,
token_prefix=default_token[:12],
description="Автоматически создан при миграции",
created_by="migration",
is_active=True,
)
session.add(token)
await session.commit()
logger.info("✅ Создан дефолтный токен веб-API из конфигурации")
return True
except Exception as error:
logger.error(f"❌ Ошибка создания дефолтного веб-API токена: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -1730,6 +2048,27 @@ async def run_universal_migration():
if not referral_migration_success:
logger.warning("⚠️ Проблемы с миграцией реферальной системы")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===")
system_settings_ready = await create_system_settings_table()
if system_settings_ready:
logger.info("✅ Таблица system_settings готова")
else:
logger.warning("⚠️ Проблемы с таблицей system_settings")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ WEB_API_TOKENS ===")
web_api_tokens_ready = await create_web_api_tokens_table()
if web_api_tokens_ready:
logger.info("✅ Таблица web_api_tokens готова")
else:
logger.warning("⚠️ Проблемы с таблицей web_api_tokens")
logger.info("=== ПРОВЕРКА БАЗОВЫХ ТОКЕНОВ ВЕБ-API ===")
default_token_ready = await ensure_default_web_api_token()
if default_token_ready:
logger.info("✅ Бутстрап токен веб-API готов")
else:
logger.warning("⚠️ Не удалось создать бутстрап токен веб-API")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ CRYPTOBOT ===")
cryptobot_created = await create_cryptobot_payments_table()
if cryptobot_created:
@@ -1799,6 +2138,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ CRYPTO LINK ДЛЯ ПОДПИСОК ===")
crypto_link_added = await add_subscription_crypto_link_column()
if crypto_link_added:
logger.info("✅ Колонка subscription_crypto_link готова")
else:
logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
try:
async with engine.begin() as conn:
@@ -1954,7 +2300,10 @@ async def check_migration_status():
"users_promo_group_column": False,
"promo_groups_period_discounts_column": False,
"promo_groups_auto_assign_column": False,
"promo_groups_addon_discount_column": False,
"users_auto_promo_group_assigned_column": False,
"users_auto_promo_group_threshold_column": False,
"subscription_crypto_link_column": False,
}
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
@@ -1970,7 +2319,10 @@ async def check_migration_status():
status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id')
status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts')
status["promo_groups_auto_assign_column"] = await check_column_exists('promo_groups', 'auto_assign_total_spent_kopeks')
status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons')
status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned')
status["users_auto_promo_group_threshold_column"] = await check_column_exists('users', 'auto_promo_group_threshold_kopeks')
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
media_fields_exist = (
await check_column_exists('broadcast_history', 'has_media') and
@@ -2006,7 +2358,10 @@ async def check_migration_status():
"users_promo_group_column": "Колонка promo_group_id у пользователей",
"promo_groups_period_discounts_column": "Колонка period_discounts у промо-групп",
"promo_groups_auto_assign_column": "Колонка auto_assign_total_spent_kopeks у промо-групп",
"promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп",
"users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей",
"users_auto_promo_group_threshold_column": "Порог последней авто-промогруппы у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
}
for check_key, check_status in status.items():
+18 -2
View File
@@ -58,6 +58,8 @@ class RemnaWaveUser:
ss_password: Optional[str] = None
first_connected_at: Optional[datetime] = None
last_triggered_threshold: int = 0
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
@dataclass
@@ -92,6 +94,8 @@ class SubscriptionInfo:
ss_conf_links: Dict[str, str]
subscription_url: str
happ: Optional[Dict[str, str]]
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None
class RemnaWaveAPIError(Exception):
@@ -584,6 +588,10 @@ class RemnaWaveAPI:
def _parse_user(self, user_data: Dict) -> RemnaWaveUser:
happ_data = user_data.get('happ') or {}
happ_link = happ_data.get('link') or happ_data.get('url')
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
return RemnaWaveUser(
uuid=user_data['uuid'],
short_uuid=user_data['shortUuid'],
@@ -612,7 +620,9 @@ class RemnaWaveAPI:
vless_uuid=user_data.get('vlessUuid'),
ss_password=user_data.get('ssPassword'),
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0)
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0),
happ_link=happ_link,
happ_crypto_link=happ_crypto_link
)
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
@@ -645,13 +655,19 @@ class RemnaWaveAPI:
)
def _parse_subscription_info(self, data: Dict) -> SubscriptionInfo:
happ_data = data.get('happ') or {}
happ_link = happ_data.get('link') or happ_data.get('url')
happ_crypto_link = happ_data.get('cryptoLink') or happ_data.get('crypto_link')
return SubscriptionInfo(
is_found=data['isFound'],
user=data.get('user'),
links=data.get('links', []),
ss_conf_links=data.get('ssConfLinks', {}),
subscription_url=data.get('subscriptionUrl', ''),
happ=data.get('happ')
happ=data.get('happ'),
happ_link=happ_link,
happ_crypto_link=happ_crypto_link
)
File diff suppressed because it is too large Load Diff
+126 -22
View File
@@ -3,6 +3,7 @@ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Dict, Optional, Tuple
from aiogram import Dispatcher, types, F
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,14 +29,50 @@ from app.utils.pricing_utils import format_period_description
logger = logging.getLogger(__name__)
def _format_discount_line(texts, group) -> str:
def _format_discount_lines(texts, group) -> list[str]:
return [
texts.t(
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER",
"💸 Скидки промогруппы:",
),
texts.t(
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS",
"• Серверы: {percent}%",
).format(percent=group.server_discount_percent),
texts.t(
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC",
"• Трафик: {percent}%",
).format(percent=group.traffic_discount_percent),
texts.t(
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES",
"• Устройства: {percent}%",
).format(percent=group.device_discount_percent),
]
def _format_addon_discounts_line(texts, group: PromoGroup) -> str:
enabled = getattr(group, "apply_discounts_to_addons", True)
if enabled:
return texts.t(
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED",
"🧩 Скидки на доп. услуги: <b>включены</b>",
)
return texts.t(
"ADMIN_PROMO_GROUPS_DISCOUNTS",
"Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
).format(
servers=group.server_discount_percent,
traffic=group.traffic_discount_percent,
devices=group.device_discount_percent,
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED",
"🧩 Скидки на доп. услуги: <b>отключены</b>",
)
def _get_addon_discounts_button_text(texts, group: PromoGroup) -> str:
enabled = getattr(group, "apply_discounts_to_addons", True)
if enabled:
return texts.t(
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE",
"🧩 Отключить скидки на доп. услуги",
)
return texts.t(
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE",
"🧩 Включить скидки на доп. услуги",
)
@@ -254,11 +291,10 @@ def _build_edit_menu_content(
"✏️ Настройки промогруппы «{name}»",
).format(name=group.name)
lines = [
header,
_format_discount_line(texts, group),
_format_auto_assign_line(texts, group),
]
lines = [header]
lines.extend(_format_discount_lines(texts, group))
lines.append(_format_addon_discounts_line(texts, group))
lines.append(_format_auto_assign_line(texts, group))
period_lines = _format_period_discounts_lines(texts, group, language)
lines.extend(period_lines)
@@ -318,6 +354,12 @@ def _build_edit_menu_content(
callback_data=f"promo_group_edit_field_{group.id}_periods",
)
],
[
types.InlineKeyboardButton(
text=_get_addon_discounts_button_text(texts, group),
callback_data=f"promo_group_toggle_addons_{group.id}",
)
],
[
types.InlineKeyboardButton(
text=texts.t(
@@ -362,8 +404,23 @@ async def _send_edit_menu_after_update(
menu_text, keyboard = _build_edit_menu_content(texts, group, language)
parts = [part for part in [success_message, menu_text] if part]
text = "\n\n".join(parts)
from_user = getattr(message, "from_user", None)
if getattr(from_user, "is_bot", False):
try:
await message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML",
)
return
except TelegramBadRequest:
pass
await message.answer(
"\n\n".join(parts),
text,
reply_markup=keyboard,
parse_mode="HTML",
)
@@ -398,13 +455,15 @@ async def show_promo_groups_menu(
)
group_lines = [
f"{'' if group.is_default else '🎯'} <b>{group.name}</b>{default_suffix}",
_format_discount_line(texts, group),
_format_auto_assign_line(texts, group),
]
group_lines.extend(_format_discount_lines(texts, group))
group_lines.append(_format_auto_assign_line(texts, group))
group_lines.append(
texts.t(
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
"Участников: {count}",
).format(count=member_count),
]
).format(count=member_count)
)
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
group_lines.extend(period_lines)
@@ -472,14 +531,16 @@ async def show_promo_group_details(
texts.t(
"ADMIN_PROMO_GROUP_DETAILS_TITLE",
"💳 <b>Промогруппа:</b> {name}",
).format(name=group.name),
_format_discount_line(texts, group),
_format_auto_assign_line(texts, group),
).format(name=group.name)
]
lines.extend(_format_discount_lines(texts, group))
lines.append(_format_auto_assign_line(texts, group))
lines.append(
texts.t(
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
"Участников: {count}",
).format(count=member_count),
]
).format(count=member_count)
)
period_lines = _format_period_discounts_lines(texts, group, db_user.language)
lines.extend(period_lines)
@@ -1192,6 +1253,45 @@ async def delete_promo_group_confirmed(
await callback.answer()
@admin_required
@error_handler
async def toggle_promo_group_addon_discounts(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
group = await _get_group_or_alert(callback, db)
if not group:
return
texts = get_texts(db_user.language)
new_value = not getattr(group, "apply_discounts_to_addons", True)
group = await update_promo_group(
db,
group,
apply_discounts_to_addons=new_value,
)
status_text = texts.t(
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED"
if new_value
else "ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED",
"🧩 Скидки на докупку доп. услуг {status}.",
).format(status="<b>включены</b>" if new_value else "<b>отключены</b>")
await _send_edit_menu_after_update(
callback.message,
texts,
group,
db_user.language,
status_text,
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups")
dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_"))
@@ -1200,6 +1300,10 @@ def register_handlers(dp: Dispatcher):
prompt_edit_promo_group_field,
F.data.startswith("promo_group_edit_field_"),
)
dp.callback_query.register(
toggle_promo_group_addon_discounts,
F.data.startswith("promo_group_toggle_addons_"),
)
dp.callback_query.register(
start_edit_promo_group,
F.data.regexp(r"^promo_group_edit_\d+$"),
+14 -6
View File
@@ -30,14 +30,22 @@ async def show_remnawave_menu(
):
remnawave_service = RemnaWaveService()
connection_test = await remnawave_service.test_api_connection()
status_emoji = "" if connection_test["status"] == "connected" else ""
status = connection_test.get("status")
if status == "connected":
status_emoji = ""
elif status == "not_configured":
status_emoji = ""
else:
status_emoji = ""
api_url_display = settings.REMNAWAVE_API_URL or ""
text = f"""
🖥 <b>Управление Remnawave</b>
📡 <b>Соединение:</b> {status_emoji} {connection_test["message"]}
🌐 <b>URL:</b> <code>{settings.REMNAWAVE_API_URL}</code>
📡 <b>Соединение:</b> {status_emoji} {connection_test.get("message", "Нет данных")}
🌐 <b>URL:</b> <code>{api_url_display}</code>
Выберите действие:
"""
@@ -194,7 +202,7 @@ async def show_traffic_stats(
remnawave_service = RemnaWaveService()
try:
async with remnawave_service.api as api:
async with remnawave_service.get_api_client() as api:
bandwidth_stats = await api.get_bandwidth_stats()
realtime_usage = await api.get_nodes_realtime_usage()
+154 -3
View File
@@ -1,3 +1,4 @@
import html
import logging
from aiogram import Dispatcher, types, F
from aiogram.fsm.context import FSMContext
@@ -15,6 +16,7 @@ from app.database.crud.server_squad import (
create_server_squad,
get_available_server_squads,
update_server_squad_promo_groups,
get_server_connected_users,
)
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.services.remnawave_service import RemnaWaveService
@@ -73,6 +75,11 @@ def _build_server_edit_view(server):
text="👥 Лимит", callback_data=f"admin_server_edit_limit_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="👥 Юзеры", callback_data=f"admin_server_users_{server.id}"
),
],
[
types.InlineKeyboardButton(
text="🎯 Промогруппы", callback_data=f"admin_server_edit_promo_{server.id}"
@@ -276,7 +283,7 @@ async def sync_servers_with_remnawave(
)
return
created, updated, disabled = await sync_with_remnawave(db, squads)
created, updated, removed = await sync_with_remnawave(db, squads)
await cache.delete_pattern("available_countries*")
@@ -286,7 +293,7 @@ async def sync_servers_with_remnawave(
📊 <b>Результаты:</b>
Создано новых серверов: {created}
Обновлено существующих: {updated}
Отключено неактивных: {disabled}
Удалено отсутствующих: {removed}
Всего обработано: {len(squads)}
Новые серверы созданы как недоступные.
@@ -314,7 +321,7 @@ async def sync_servers_with_remnawave(
[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_servers")]
])
)
await callback.answer()
@@ -343,6 +350,149 @@ async def show_server_edit_menu(
await callback.answer()
@admin_required
@error_handler
async def show_server_users(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
payload = callback.data.split("admin_server_users_", 1)[-1]
payload_parts = payload.split("_")
server_id = int(payload_parts[0])
page = int(payload_parts[1]) if len(payload_parts) > 1 else 1
page = max(page, 1)
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден!", show_alert=True)
return
users = await get_server_connected_users(db, server_id)
total_users = len(users)
page_size = 10
total_pages = max((total_users + page_size - 1) // page_size, 1)
if page > total_pages:
page = total_pages
start_index = (page - 1) * page_size
end_index = start_index + page_size
page_users = users[start_index:end_index]
safe_name = html.escape(server.display_name or "")
safe_uuid = html.escape(server.squad_uuid or "")
header = [
"🌐 <b>Пользователи сервера</b>",
"",
f"• Сервер: {safe_name}",
f"• UUID: <code>{safe_uuid}</code>",
f"• Подключений: {total_users}",
]
if total_pages > 1:
header.append(f"• Страница: {page}/{total_pages}")
header.append("")
text = "\n".join(header)
def _get_status_icon(status_text: str) -> str:
if not status_text:
return ""
parts = status_text.split(" ", 1)
return parts[0] if parts else status_text
if users:
lines = []
for index, user in enumerate(page_users, start=start_index + 1):
safe_user_name = html.escape(user.full_name)
lines.append(f"{index}. {safe_user_name}")
text += "\n" + "\n".join(lines)
else:
text += "Пользователи не найдены."
keyboard: list[list[types.InlineKeyboardButton]] = []
for user in page_users:
display_name = user.full_name
if len(display_name) > 30:
display_name = display_name[:27] + "..."
subscription_status = (
user.subscription.status_display
if user.subscription
else "❌ Нет подписки"
)
status_icon = _get_status_icon(subscription_status)
if status_icon:
button_text = f"{status_icon} {display_name}"
else:
button_text = display_name
keyboard.append([
types.InlineKeyboardButton(
text=button_text,
callback_data=f"admin_user_manage_{user.id}",
)
])
if total_pages > 1:
navigation_buttons: list[types.InlineKeyboardButton] = []
if page > 1:
navigation_buttons.append(
types.InlineKeyboardButton(
text="⬅️ Предыдущая",
callback_data=f"admin_server_users_{server_id}_{page - 1}",
)
)
navigation_buttons.append(
types.InlineKeyboardButton(
text=f"Стр. {page}/{total_pages}",
callback_data=f"admin_server_users_{server_id}_{page}",
)
)
if page < total_pages:
navigation_buttons.append(
types.InlineKeyboardButton(
text="Следующая ➡️",
callback_data=f"admin_server_users_{server_id}_{page + 1}",
)
)
keyboard.append(navigation_buttons)
keyboard.append([
types.InlineKeyboardButton(
text="⬅️ К серверу", callback_data=f"admin_server_edit_{server_id}"
)
])
keyboard.append([
types.InlineKeyboardButton(
text="⬅️ К списку", callback_data="admin_servers_list"
)
])
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 toggle_server_availability(
@@ -1105,6 +1255,7 @@ def register_handlers(dp: Dispatcher):
& ~F.data.contains("promo"),
)
dp.callback_query.register(toggle_server_availability, F.data.startswith("admin_server_toggle_"))
dp.callback_query.register(show_server_users, F.data.startswith("admin_server_users_"))
dp.callback_query.register(start_server_edit_name, F.data.startswith("admin_server_edit_name_"))
dp.callback_query.register(start_server_edit_price, F.data.startswith("admin_server_edit_price_"))
+95 -4
View File
@@ -79,12 +79,16 @@ async def show_admin_tickets(
ticket_data = []
for ticket in tickets:
user_name = ticket.user.full_name if ticket.user else "Unknown"
username = ticket.user.username if ticket.user else None
telegram_id = ticket.user.telegram_id if ticket.user else None
ticket_data.append({
'id': ticket.id,
'title': ticket.title,
'status_emoji': ticket.status_emoji,
'priority_emoji': ticket.priority_emoji,
'user_name': user_name,
'username': username,
'telegram_id': telegram_id,
'is_closed': ticket.is_closed,
'locked_emoji': ("🔒" if ticket.is_user_reply_blocked else "")
})
@@ -169,9 +173,13 @@ async def view_admin_ticket(
}.get(ticket.status, ticket.status)
user_name = ticket.user.full_name if ticket.user else "Unknown"
telegram_id_display = ticket.user.telegram_id if ticket.user else ""
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
ticket_text += f"👤 Пользователь: {user_name}\n"
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
ticket_text += f"📱 Username: @{username_display}\n"
ticket_text += f"📝 Заголовок: {ticket.title}\n"
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
@@ -201,6 +209,33 @@ async def view_admin_ticket(
db_user.language,
is_user_blocked=ticket.is_user_reply_blocked
)
# Кнопка открытия профиля пользователя в админке
try:
if ticket.user:
admin_profile_btn = types.InlineKeyboardButton(
text="👤 К пользователю",
callback_data=f"admin_user_manage_{ticket.user.id}_from_ticket_{ticket.id}"
)
keyboard.inline_keyboard.insert(0, [admin_profile_btn])
except Exception:
pass
# Кнопки ЛС и профиль
try:
if ticket.user and ticket.user.telegram_id:
buttons_row = []
# DM: при наличии username используем tg://resolve, иначе fallback по ID
if ticket.user.username:
pm_url = f"tg://resolve?domain={ticket.user.username}"
else:
pm_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
# Профиль: по ID
profile_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
keyboard.inline_keyboard.insert(0, buttons_row)
except Exception:
pass
if has_photos:
try:
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")])
@@ -445,6 +480,17 @@ async def close_admin_ticket(
# audit
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
# обогатим details контактами пользователя тикета
details = {}
try:
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
if t and t.user:
details.update({
"target_telegram_id": t.user.telegram_id,
"target_username": t.user.username,
})
except Exception:
pass
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
@@ -453,7 +499,7 @@ async def close_admin_ticket(
action="close_ticket",
ticket_id=ticket_id,
target_user_id=None,
details={}
details=details
)
except Exception:
pass
@@ -645,6 +691,31 @@ async def handle_admin_block_duration_input(
ticket_text += "📎 Вложение: фото\n\n"
kb = get_admin_ticket_view_keyboard(updated.id, updated.is_closed, db_user.language, is_user_blocked=updated.is_user_reply_blocked)
# Кнопка открытия профиля пользователя в админке
try:
if updated.user:
admin_profile_btn = types.InlineKeyboardButton(
text="👤 К пользователю",
callback_data=f"admin_user_manage_{updated.user.id}_from_ticket_{updated.id}"
)
kb.inline_keyboard.insert(0, [admin_profile_btn])
except Exception:
pass
# Кнопки ЛС и профиль при обновлении карточки
try:
if updated.user and updated.user.telegram_id:
buttons_row = []
if updated.user.username:
pm_url = f"tg://resolve?domain={updated.user.username}"
else:
pm_url = f"tg://user?id={updated.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="✉ Написать в ЛС", url=pm_url))
profile_url = f"tg://user?id={updated.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
kb.inline_keyboard.insert(0, buttons_row)
except Exception:
pass
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in updated.messages or [])
if has_photos:
try:
@@ -699,6 +770,16 @@ async def unblock_user_in_ticket(
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", ""))
details = {}
try:
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
if t and t.user:
details.update({
"target_telegram_id": t.user.telegram_id,
"target_username": t.user.username,
})
except Exception:
pass
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
@@ -707,7 +788,7 @@ async def unblock_user_in_ticket(
action="unblock_user",
ticket_id=ticket_id,
target_user_id=None,
details={}
details=details
)
except Exception:
pass
@@ -741,6 +822,16 @@ async def block_user_permanently(
# audit
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
details = {}
try:
t = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_user=True)
if t and t.user:
details.update({
"target_telegram_id": t.user.telegram_id,
"target_username": t.user.username,
})
except Exception:
pass
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
@@ -749,7 +840,7 @@ async def block_user_permanently(
action="block_user_perm",
ticket_id=ticket_id,
target_user_id=None,
details={}
details=details
)
except Exception:
pass
+292 -54
View File
@@ -1,5 +1,6 @@
import logging
from datetime import datetime, timedelta
from typing import Optional
from aiogram import Dispatcher, types, F
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.fsm.context import FSMContext
@@ -20,12 +21,19 @@ from app.keyboards.admin import (
)
from app.localization.texts import get_texts
from app.services.user_service import UserService
from app.services.admin_notification_service import AdminNotificationService
from app.database.crud.promo_group import get_promo_groups_with_counts
from app.utils.decorators import admin_required, error_handler
from app.utils.formatters import format_datetime, format_time_ago
from app.services.remnawave_service import RemnaWaveService
from app.external.remnawave_api import TrafficLimitStrategy
from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_uuid, get_server_squad_by_id
from app.database.crud.server_squad import (
get_all_server_squads,
get_server_squad_by_uuid,
get_server_squad_by_id,
get_server_ids_by_uuids,
)
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
@@ -348,32 +356,46 @@ async def show_users_statistics(
user_service = UserService()
stats = await user_service.get_user_statistics(db)
from sqlalchemy import select, func, and_
with_sub_result = await db.execute(
select(func.count(User.id))
.join(Subscription)
from sqlalchemy import select, func, or_
current_time = datetime.utcnow()
active_subscription_query = (
select(func.count(Subscription.id))
.join(User, Subscription.user_id == User.id)
.where(
and_(
User.status == UserStatus.ACTIVE.value,
Subscription.is_active == True
)
User.status == UserStatus.ACTIVE.value,
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.TRIAL.value,
]
),
Subscription.end_date > current_time,
)
)
users_with_subscription = with_sub_result.scalar() or 0
trial_result = await db.execute(
select(func.count(User.id))
.join(Subscription)
users_with_subscription = (
await db.execute(active_subscription_query)
).scalar() or 0
trial_subscription_query = (
select(func.count(Subscription.id))
.join(User, Subscription.user_id == User.id)
.where(
and_(
User.status == UserStatus.ACTIVE.value,
Subscription.is_trial == True,
Subscription.is_active == True
)
User.status == UserStatus.ACTIVE.value,
Subscription.end_date > current_time,
or_(
Subscription.status == SubscriptionStatus.TRIAL.value,
Subscription.is_trial.is_(True),
),
)
)
trial_users = trial_result.scalar() or 0
trial_users = (await db.execute(trial_subscription_query)).scalar() or 0
users_without_subscription = max(
stats["active_users"] - users_with_subscription,
0,
)
avg_balance_result = await db.execute(
select(func.avg(User.balance_kopeks))
@@ -392,7 +414,7 @@ async def show_users_statistics(
📱 <b>Подписки:</b>
С активной подпиской: {users_with_subscription}
На триале: {trial_users}
Без подписки: {stats['active_users'] - users_with_subscription}
Без подписки: {users_without_subscription}
💰 <b>Финансы:</b>
Средний баланс: {settings.format_price(int(avg_balance))}
@@ -766,7 +788,32 @@ async def show_user_management(
state: FSMContext
):
user_id = int(callback.data.split('_')[-1])
# Поддерживаем переход "из тикета": admin_user_manage_{userId}_from_ticket_{ticketId}
parts = callback.data.split('_')
try:
user_id = int(parts[3]) # admin_user_manage_{userId}
except Exception:
user_id = int(callback.data.split('_')[-1])
origin_ticket_id = None
if "from" in parts and "ticket" in parts:
try:
origin_ticket_id = int(parts[-1])
except Exception:
origin_ticket_id = None
# Если пришли из тикета — запомним в состоянии, чтобы сохранять кнопку возврата
try:
if origin_ticket_id:
await state.update_data(origin_ticket_id=origin_ticket_id, origin_ticket_user_id=user_id)
except Exception:
pass
# Если не пришло в колбэке — попробуем достать из состояния
if origin_ticket_id is None:
try:
data_state = await state.get_data()
if data_state.get("origin_ticket_user_id") == user_id:
origin_ticket_id = data_state.get("origin_ticket_id")
except Exception:
pass
# Проверяем, откуда пришел пользователь
back_callback = "admin_users_list"
@@ -844,9 +891,22 @@ async def show_user_management(
if current_state == AdminStates.viewing_user_from_balance_list:
back_callback = "admin_users_balance_filter"
# Базовая клавиатура профиля
kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
# Если пришли из тикета — добавим в начало кнопку возврата к тикету
try:
if origin_ticket_id:
back_to_ticket_btn = types.InlineKeyboardButton(
text="🎫 Вернуться к тикету",
callback_data=f"admin_view_ticket_{origin_ticket_id}"
)
kb.inline_keyboard.insert(0, [back_to_ticket_btn])
except Exception:
pass
await callback.message.edit_text(
text,
reply_markup=get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
reply_markup=kb
)
await callback.answer()
@@ -941,7 +1001,7 @@ async def set_user_promo_group(
return
user_service = UserService()
success, updated_user, new_group = await user_service.update_user_promo_group(
success, updated_user, new_group, old_group = await user_service.update_user_promo_group(
db,
user_id,
group_id
@@ -959,6 +1019,27 @@ async def set_user_promo_group(
show_alert=True
)
try:
notification_service = AdminNotificationService(callback.bot)
reason = (
f"Назначено администратором {db_user.full_name} (ID: {db_user.telegram_id})"
)
await notification_service.send_user_promo_group_change_notification(
db,
updated_user,
old_group,
new_group,
reason=reason,
initiator=db_user,
automatic=False,
)
except Exception as notify_error:
logger.error(
"Ошибка отправки уведомления о смене промогруппы пользователя %s: %s",
updated_user.telegram_id,
notify_error,
)
@admin_required
@@ -1988,7 +2069,7 @@ async def toggle_user_server(
if user.remnawave_uuid:
try:
remnawave_service = RemnaWaveService()
async with remnawave_service.api as api:
async with remnawave_service.get_api_client() as api:
await api.update_user(
uuid=user.remnawave_uuid,
active_internal_squads=current_squads,
@@ -2332,7 +2413,7 @@ async def reset_user_devices(
return
remnawave_service = RemnaWaveService()
async with remnawave_service.api as api:
async with remnawave_service.get_api_client() as api:
success = await api.reset_user_devices(user.remnawave_uuid)
if success:
@@ -2372,7 +2453,7 @@ async def _update_user_devices(db: AsyncSession, user_id: int, devices: int, adm
if user.remnawave_uuid:
try:
remnawave_service = RemnaWaveService()
async with remnawave_service.api as api:
async with remnawave_service.get_api_client() as api:
await api.update_user(
uuid=user.remnawave_uuid,
hwid_device_limit=devices,
@@ -2414,7 +2495,7 @@ async def _update_user_traffic(db: AsyncSession, user_id: int, traffic_gb: int,
from app.external.remnawave_api import TrafficLimitStrategy
remnawave_service = RemnaWaveService()
async with remnawave_service.api as api:
async with remnawave_service.get_api_client() as api:
await api.update_user(
uuid=user.remnawave_uuid,
traffic_limit_bytes=traffic_gb * (1024**3) if traffic_gb > 0 else 0,
@@ -2599,6 +2680,56 @@ async def _grant_paid_subscription(db: AsyncSession, user_id: int, days: int, ad
logger.error(f"Ошибка выдачи платной подписки: {e}")
return False
async def _calculate_subscription_period_price(
db: AsyncSession,
target_user: User,
subscription: Subscription,
period_days: int,
subscription_service: Optional[SubscriptionService] = None,
) -> int:
"""Рассчитывает стоимость подписки для администратора с учётом всех параметров."""
service = subscription_service or SubscriptionService()
connected_squads = list(subscription.connected_squads or [])
server_ids = []
if connected_squads:
try:
server_ids = await get_server_ids_by_uuids(db, connected_squads)
if len(server_ids) != len(connected_squads):
logger.warning(
"Не удалось сопоставить все сервера подписки пользователя %s для расчёта цены",
target_user.telegram_id,
)
except Exception as e:
logger.error(
"Не удалось получить идентификаторы серверов для расчёта цены подписки пользователя %s: %s",
target_user.telegram_id,
e,
)
server_ids = []
traffic_limit_gb = subscription.traffic_limit_gb
if traffic_limit_gb is None:
traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
device_limit = subscription.device_limit
if not device_limit or device_limit < 0:
device_limit = settings.DEFAULT_DEVICE_LIMIT
total_price, _ = await service.calculate_subscription_price(
period_days=period_days,
traffic_gb=traffic_limit_gb,
server_squad_ids=server_ids,
devices=device_limit,
db=db,
user=target_user,
promo_group=target_user.promo_group,
)
return total_price
@admin_required
@error_handler
async def cleanup_inactive_users(
@@ -2697,30 +2828,55 @@ async def admin_buy_subscription(
return
available_periods = settings.get_available_subscription_periods()
subscription_service = SubscriptionService()
period_buttons = []
for period in available_periods:
price_attr = f"PRICE_{period}_DAYS"
if hasattr(settings, price_attr):
price_kopeks = getattr(settings, price_attr)
price_rubles = price_kopeks // 100
period_buttons.append([
types.InlineKeyboardButton(
text=f"{period} дней ({price_rubles} ₽)",
callback_data=f"admin_buy_sub_confirm_{user_id}_{period}_{price_kopeks}"
)
])
try:
price_kopeks = await _calculate_subscription_period_price(
db,
target_user,
subscription,
period,
subscription_service=subscription_service,
)
except Exception as e:
logger.error(
"Ошибка расчёта стоимости подписки для пользователя %s и периода %s дней: %s",
target_user.telegram_id,
period,
e,
)
continue
period_buttons.append([
types.InlineKeyboardButton(
text=f"{period} дней ({settings.format_price(price_kopeks)})",
callback_data=f"admin_buy_sub_confirm_{user_id}_{period}_{price_kopeks}"
)
])
if not period_buttons:
await callback.answer("❌ Не удалось рассчитать стоимость подписки", show_alert=True)
return
period_buttons.append([
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_user_subscription_{user_id}"
)
])
text = f"💳 <b>Покупка подписки для пользователя</b>\n\n"
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
servers_count = len(subscription.connected_squads or [])
text += f"📶 Трафик: {traffic_text}\n"
text += f"📱 Устройства: {devices_limit}\n"
text += f"🌐 Серверов: {servers_count}\n\n"
text += "Выберите период подписки:\n"
await callback.message.edit_text(
@@ -2740,7 +2896,7 @@ async def admin_buy_subscription_confirm(
parts = callback.data.split('_')
user_id = int(parts[4])
period_days = int(parts[5])
price_kopeks = int(parts[6])
price_kopeks_from_callback = int(parts[6]) if len(parts) > 6 else None
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
@@ -2751,7 +2907,38 @@ async def admin_buy_subscription_confirm(
target_user = profile["user"]
subscription = profile["subscription"]
if not subscription:
await callback.answer("❌ У пользователя нет подписки", show_alert=True)
return
subscription_service = SubscriptionService()
try:
price_kopeks = await _calculate_subscription_period_price(
db,
target_user,
subscription,
period_days,
subscription_service=subscription_service,
)
except Exception as e:
logger.error(
"Ошибка расчёта стоимости подписки при подтверждении админом для пользователя %s: %s",
target_user.telegram_id,
e,
)
await callback.answer("❌ Не удалось рассчитать стоимость подписки", show_alert=True)
return
if price_kopeks_from_callback is not None and price_kopeks_from_callback != price_kopeks:
logger.info(
"Стоимость подписки для пользователя %s изменилась с %s до %s при подтверждении",
target_user.telegram_id,
price_kopeks_from_callback,
price_kopeks,
)
if target_user.balance_kopeks < price_kopeks:
missing_kopeks = price_kopeks - target_user.balance_kopeks
await callback.message.edit_text(
@@ -2770,12 +2957,17 @@ async def admin_buy_subscription_confirm(
await callback.answer()
return
price_rubles = price_kopeks // 100
text = f"💳 <b>Подтверждение покупки подписки</b>\n\n"
text += f"👤 {target_user.full_name} (ID: {target_user.telegram_id})\n"
text += f"📅 Период подписки: {period_days} дней\n"
text += f"💰 Стоимость: {settings.format_price(price_kopeks)}\n"
text += f"💰 Баланс пользователя: {settings.format_price(target_user.balance_kopeks)}\n\n"
traffic_text = "Безлимит" if (subscription.traffic_limit_gb or 0) <= 0 else f"{subscription.traffic_limit_gb} ГБ"
devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
servers_count = len(subscription.connected_squads or [])
text += f"📶 Трафик: {traffic_text}\n"
text += f"📱 Устройства: {devices_limit}\n"
text += f"🌐 Серверов: {servers_count}\n\n"
text += "Вы уверены, что хотите купить подписку для этого пользователя?"
keyboard = [
@@ -2810,7 +3002,7 @@ async def admin_buy_subscription_execute(
parts = callback.data.split('_')
user_id = int(parts[4])
period_days = int(parts[5])
price_kopeks = int(parts[6])
price_kopeks_from_callback = int(parts[6]) if len(parts) > 6 else None
user_service = UserService()
profile = await user_service.get_user_profile(db, user_id)
@@ -2821,7 +3013,38 @@ async def admin_buy_subscription_execute(
target_user = profile["user"]
subscription = profile["subscription"]
if not subscription:
await callback.answer("❌ У пользователя нет подписки", show_alert=True)
return
subscription_service = SubscriptionService()
try:
price_kopeks = await _calculate_subscription_period_price(
db,
target_user,
subscription,
period_days,
subscription_service=subscription_service,
)
except Exception as e:
logger.error(
"Ошибка расчёта стоимости подписки при списании средств админом для пользователя %s: %s",
target_user.telegram_id,
e,
)
await callback.answer("❌ Не удалось рассчитать стоимость подписки", show_alert=True)
return
if price_kopeks_from_callback is not None and price_kopeks_from_callback != price_kopeks:
logger.info(
"Стоимость подписки для пользователя %s изменилась с %s до %s перед списанием",
target_user.telegram_id,
price_kopeks_from_callback,
price_kopeks,
)
if target_user.balance_kopeks < price_kopeks:
await callback.answer("❌ Недостаточно средств на балансе пользователя", show_alert=True)
return
@@ -2839,16 +3062,31 @@ async def admin_buy_subscription_execute(
if subscription:
current_time = datetime.utcnow()
bonus_period = timedelta()
if (
subscription.is_trial
and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID
and subscription.end_date
):
remaining_trial_delta = subscription.end_date - current_time
if remaining_trial_delta.total_seconds() > 0:
bonus_period = remaining_trial_delta
logger.info(
"Админ продлевает подписку: добавляем оставшееся время триала (%s) пользователю %s",
bonus_period,
target_user.telegram_id,
)
if subscription.end_date <= current_time:
subscription.start_date = current_time
subscription.end_date = current_time + timedelta(days=period_days)
subscription.end_date = current_time + timedelta(days=period_days) + bonus_period
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = current_time
if subscription.is_trial or not subscription.is_active:
subscription.is_trial = False
subscription.is_trial = False
if subscription.traffic_limit_gb != 0:
subscription.traffic_limit_gb = 0
subscription.device_limit = settings.DEFAULT_DEVICE_LIMIT
@@ -2873,7 +3111,7 @@ async def admin_buy_subscription_execute(
remnawave_service = RemnaWaveService()
if target_user.remnawave_uuid:
async with remnawave_service.api as api:
async with remnawave_service.get_api_client() as api:
remnawave_user = await api.update_user(
uuid=target_user.remnawave_uuid,
status=UserStatus.ACTIVE if subscription.is_active else UserStatus.EXPIRED,
@@ -2890,7 +3128,7 @@ async def admin_buy_subscription_execute(
)
else:
username = f"user_{target_user.telegram_id}"
async with remnawave_service.api as api:
async with remnawave_service.get_api_client() as api:
remnawave_user = await api.create_user(
username=username,
expire_at=subscription.end_date,
+24 -11
View File
@@ -386,9 +386,9 @@ async def start_pal24_payment(
message_text = texts.t(
"PAL24_TOPUP_PROMPT",
(
"💳 <b>Оплата через PayPalych</b>\n\n"
"🏦 <b>Оплата через PayPalych (СБП)</b>\n\n"
"Введите сумму для пополнения от 100 до 1 000 000 ₽.\n"
"Оплата проходит через защищенную платформу PayPalych."
"Оплата проходит через систему быстрых платежей PayPalych."
),
)
@@ -1002,7 +1002,9 @@ async def process_pal24_payment_amount(
language=db_user.language,
)
if not payment_result or not payment_result.get("link_url"):
if not payment_result or not (
payment_result.get("link_url") or payment_result.get("link_page_url")
):
await message.answer(
texts.t(
"PAL24_PAYMENT_ERROR",
@@ -1012,7 +1014,10 @@ async def process_pal24_payment_amount(
await state.clear()
return
link_url = payment_result.get("link_url")
payment_url = (
payment_result.get("link_page_url")
or payment_result.get("link_url")
)
bill_id = payment_result.get("bill_id")
local_payment_id = payment_result.get("local_payment_id")
@@ -1020,8 +1025,8 @@ async def process_pal24_payment_amount(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("PAL24_PAY_BUTTON", "💳 Оплатить через PayPalych"),
url=link_url,
text=texts.t("PAL24_PAY_BUTTON", "🏦 Оплатить через PayPalych (СБП)"),
url=payment_url,
)
],
[
@@ -1037,11 +1042,11 @@ async def process_pal24_payment_amount(
message_template = texts.t(
"PAL24_PAYMENT_INSTRUCTIONS",
(
"💳 <b>Оплата через PayPalych</b>\n\n"
"🏦 <b>Оплата через PayPalych (СБП)</b>\n\n"
"💰 Сумма: {amount}\n"
"🆔 ID счета: {bill_id}\n\n"
"📱 <b>Инструкция:</b>\n"
"1. Нажмите кнопку ‘Оплатить через PayPalych’\n"
"1. Нажмите кнопку ‘Оплатить через PayPalych (СБП)\n"
"2. Следуйте подсказкам платежной системы\n"
"3. Подтвердите перевод\n"
"4. Средства зачислятся автоматически\n\n"
@@ -1226,8 +1231,10 @@ async def check_pal24_payment_status(
emoji, status_text = status_labels.get(payment.status, ("", "Неизвестно"))
payment_link = payment.link_page_url or payment.link_url
message_lines = [
"💳 Статус платежа PayPalych:\n\n",
"🏦 Статус платежа PayPalych (СБП):\n\n",
f"🆔 ID счета: {payment.bill_id}\n",
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n",
f"📊 Статус: {emoji} {status_text}\n",
@@ -1238,8 +1245,8 @@ async def check_pal24_payment_status(
message_lines.append("\n✅ Платеж успешно завершен! Средства уже на балансе.")
elif payment.status in {"NEW", "PROCESS"}:
message_lines.append("\n⏳ Платеж еще не завершен. Оплатите счет и проверьте статус позже.")
if payment.link_url:
message_lines.append(f"\n🔗 Ссылка на оплату: {payment.link_url}")
if payment_link:
message_lines.append(f"\n🔗 Ссылка на оплату: {payment_link}")
elif payment.status in {"FAIL", "UNDERPAID", "OVERPAID"}:
message_lines.append(
f"\n❌ Платеж не завершен корректно. Обратитесь в {settings.get_support_contact_display()}"
@@ -1528,6 +1535,12 @@ async def handle_quick_amount_selection(
await process_mulenpay_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "pal24":
from app.database.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
await process_pal24_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
else:
await callback.answer("❌ Неизвестный способ оплаты", show_alert=True)
return
+105 -7
View File
@@ -1,12 +1,13 @@
import logging
from aiogram import Dispatcher, types, F
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta
from datetime import datetime
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id, update_user
from app.keyboards.inline import get_main_menu_keyboard
from app.keyboards.inline import get_main_menu_keyboard, get_language_selection_keyboard
from app.localization.texts import get_texts, get_rules
from app.database.models import User
from app.utils.user_utils import mark_user_as_had_paid_subscription
@@ -24,11 +25,12 @@ logger = logging.getLogger(__name__)
async def show_main_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
db: AsyncSession,
*,
skip_callback_answer: bool = False,
):
texts = get_texts(db_user.language)
from datetime import datetime
db_user.last_activity = datetime.utcnow()
await db.commit()
@@ -59,7 +61,8 @@ async def show_main_menu(
),
parse_mode="HTML",
)
await callback.answer()
if not skip_callback_answer:
await callback.answer()
async def mark_user_as_had_paid_subscription(
@@ -74,8 +77,8 @@ async def mark_user_as_had_paid_subscription(
async def show_service_rules(
callback: types.CallbackQuery,
db_user: User,
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.database.crud.rules import get_current_rules_content
@@ -95,6 +98,90 @@ async def show_service_rules(
await callback.answer()
async def show_language_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
await callback.answer(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен.",
),
show_alert=True,
)
return
await edit_or_answer_photo(
callback=callback,
caption=texts.t("LANGUAGE_PROMPT", "🌐 Выберите язык интерфейса:"),
keyboard=get_language_selection_keyboard(
current_language=db_user.language,
include_back=True,
language=db_user.language,
),
parse_mode="HTML",
)
await callback.answer()
async def process_language_change(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
if not settings.is_language_selection_enabled():
await callback.answer(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен.",
),
show_alert=True,
)
return
selected_raw = (callback.data or "").split(":", 1)[-1]
normalized_selected = selected_raw.strip().lower()
available_map = {
lang.strip().lower(): lang.strip()
for lang in settings.get_available_languages()
if isinstance(lang, str) and lang.strip()
}
if normalized_selected not in available_map:
await callback.answer("❌ Unsupported language", show_alert=True)
return
resolved_language = available_map[normalized_selected].lower()
if db_user.language.lower() == normalized_selected:
await show_main_menu(
callback,
db_user,
db,
skip_callback_answer=True,
)
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
return
updated_user = await update_user(db, db_user, language=resolved_language)
texts = get_texts(updated_user.language)
await show_main_menu(
callback,
updated_user,
db,
skip_callback_answer=True,
)
await callback.answer(texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."))
async def handle_back_to_menu(
callback: types.CallbackQuery,
state: FSMContext,
@@ -240,3 +327,14 @@ def register_handlers(dp: Dispatcher):
show_service_rules,
F.data == "menu_rules"
)
dp.callback_query.register(
show_language_menu,
F.data == "menu_language"
)
dp.callback_query.register(
process_language_change,
F.data.startswith("language_select:"),
StateFilter(None)
)
+16
View File
@@ -9,6 +9,7 @@ from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.promocode_service import PromoCodeService
from app.services.admin_notification_service import AdminNotificationService
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
@@ -61,6 +62,21 @@ async def process_promocode(
reply_markup=get_back_keyboard(db_user.language)
)
logger.info(f"✅ Пользователь {db_user.telegram_id} активировал промокод {code}")
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_promocode_activation_notification(
db,
db_user,
result.get("promocode", {"code": code}),
result["description"],
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления об активации промокода %s: %s",
code,
notify_error,
)
else:
error_messages = {
"not_found": texts.PROMOCODE_INVALID,
+3 -1
View File
@@ -18,7 +18,9 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
logger.info(f"📋 Pre-checkout query от {query.from_user.id}: {query.total_amount} XTR, payload: {query.invoice_payload}")
if not query.invoice_payload or not query.invoice_payload.startswith("balance_"):
allowed_prefixes = ("balance_", "admin_stars_test_")
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
logger.warning(f"Невалидный payload: {query.invoice_payload}")
await query.answer(
ok=False,
+252 -48
View File
@@ -17,14 +17,19 @@ from app.database.crud.campaign import (
get_campaign_by_start_parameter,
get_campaign_by_id,
)
from app.database.models import UserStatus
from app.database.models import UserStatus, SubscriptionStatus
from app.keyboards.inline import (
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
get_rules_keyboard,
get_main_menu_keyboard,
get_post_registration_keyboard,
get_language_selection_keyboard,
)
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts, get_rules
from app.services.referral_service import process_referral_registration
from app.services.campaign_service import AdvertisingCampaignService
from app.services.admin_notification_service import AdminNotificationService
from app.services.subscription_service import SubscriptionService
from app.utils.user_utils import generate_unique_referral_code
from app.database.crud.user_message import get_random_active_message
@@ -71,8 +76,8 @@ async def _apply_campaign_bonus_if_needed(
async def handle_potential_referral_code(
message: types.Message,
state: FSMContext,
message: types.Message,
state: FSMContext,
db: AsyncSession
):
current_state = await state.get_state()
@@ -132,7 +137,80 @@ async def handle_potential_referral_code(
else:
await complete_registration(message, state, db)
return True
return True
def _get_language_prompt_text() -> str:
return "🌐 Выберите язык / Choose your language:"
async def _prompt_language_selection(message: types.Message, state: FSMContext) -> None:
logger.info(f"🌐 LANGUAGE: Запрос выбора языка для пользователя {message.from_user.id}")
await state.set_state(RegistrationStates.waiting_for_language)
await message.answer(
_get_language_prompt_text(),
reply_markup=get_language_selection_keyboard(),
)
async def _continue_registration_after_language(
*,
message: types.Message | None,
callback: types.CallbackQuery | None,
state: FSMContext,
db: AsyncSession,
) -> None:
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
target_message = callback.message if callback else message
if not target_message:
logger.warning("⚠️ LANGUAGE: Нет доступного сообщения для продолжения регистрации")
return
async def _complete_registration_wrapper():
if callback:
await complete_registration_from_callback(callback, state, db)
else:
await complete_registration(message, state, db)
if settings.SKIP_RULES_ACCEPT:
logger.info("⚙️ LANGUAGE: SKIP_RULES_ACCEPT включен - пропускаем правила")
if data.get('referral_code'):
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ LANGUAGE: Реферер найден: {referrer.id}")
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
await _complete_registration_wrapper()
else:
try:
await target_message.answer(
texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info("🔍 LANGUAGE: Ожидание ввода реферального кода")
except Exception as error:
logger.error(f"Ошибка при показе вопроса о реферальном коде после выбора языка: {error}")
await _complete_registration_wrapper()
return
rules_text = await get_rules(language)
await target_message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info("📋 LANGUAGE: Правила отправлены после выбора языка")
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
@@ -164,6 +242,22 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await state.update_data(referral_code=referral_code)
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
if campaign:
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_campaign_link_visit_notification(
db,
message.from_user,
campaign,
user,
)
except Exception as notify_error:
logger.error(
"Ошибка отправки админ уведомления о переходе по кампании %s: %s",
campaign.id,
notify_error,
)
if user and user.status != UserStatus.DELETED.value:
logger.info(f"✅ Активный пользователь найден: {user.telegram_id}")
@@ -308,55 +402,128 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
else:
logger.info(f"🆕 Новый пользователь, начинаем регистрацию")
language = DEFAULT_LANGUAGE
texts = get_texts(language)
data = await state.get_data() or {}
data['language'] = language
await state.set_data(data)
logger.info(f"💾 Установлен язык по умолчанию: {language}")
if settings.SKIP_RULES_ACCEPT:
logger.info("⚙️ SKIP_RULES_ACCEPT включен - пропускаем принятие правил")
if data.get('referral_code'):
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info(f"✅ Реферер найден: {referrer.id}")
if not data.get('language'):
if settings.is_language_selection_enabled():
await _prompt_language_selection(message, state)
return
if settings.SKIP_REFERRAL_CODE or data.get('referral_code'):
await complete_registration(message, state, db)
else:
try:
await message.answer(
texts.t(
"REFERRAL_CODE_QUESTION",
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language)
default_language = (
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
if isinstance(settings.DEFAULT_LANGUAGE, str)
else DEFAULT_LANGUAGE
)
normalized_default = default_language.split("-")[0].lower()
data['language'] = normalized_default
await state.set_data(data)
logger.info(
"🌐 LANGUAGE: выбор языка отключен, устанавливаем язык по умолчанию '%s'",
normalized_default,
)
await _continue_registration_after_language(
message=message,
callback=None,
state=state,
db=db,
)
async def process_language_selection(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession,
):
logger.info(
f"🌐 LANGUAGE: Пользователь {callback.from_user.id} выбрал язык ({callback.data})"
)
if not settings.is_language_selection_enabled():
data = await state.get_data() or {}
default_language = (
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
if isinstance(settings.DEFAULT_LANGUAGE, str)
else DEFAULT_LANGUAGE
)
normalized_default = default_language.split("-")[0].lower()
data['language'] = normalized_default
await state.set_data(data)
texts = get_texts(normalized_default)
try:
await callback.message.edit_text(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info("🔍 Ожидание ввода реферального кода")
except Exception as e:
logger.error(f"Ошибка при показе вопроса о реферальном коде: {e}")
await complete_registration(message, state, db)
)
except Exception:
await callback.message.answer(
texts.t(
"LANGUAGE_SELECTION_DISABLED",
"⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
)
)
await callback.answer()
await _continue_registration_after_language(
message=None,
callback=callback,
state=state,
db=db,
)
return
rules_text = await get_rules(language)
await message.answer(
rules_text,
reply_markup=get_rules_keyboard(language)
)
logger.info(f"📋 Правила отправлены")
selected_raw = (callback.data or "").split(":", 1)[-1]
normalized_selected = selected_raw.strip().lower()
await state.set_state(RegistrationStates.waiting_for_rules_accept)
current_state = await state.get_state()
logger.info(f"📊 Установлено состояние: {current_state}")
available_map = {
lang.strip().lower(): lang.strip()
for lang in settings.get_available_languages()
if isinstance(lang, str) and lang.strip()
}
if normalized_selected not in available_map:
logger.warning(
f"⚠️ LANGUAGE: Выбран недоступный язык '{normalized_selected}' пользователем {callback.from_user.id}"
)
await callback.answer("❌ Unsupported language", show_alert=True)
return
resolved_language = available_map[normalized_selected].lower()
data = await state.get_data() or {}
data['language'] = resolved_language
await state.set_data(data)
texts = get_texts(resolved_language)
try:
await callback.message.edit_text(
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
)
except Exception as error:
logger.warning(
f"⚠️ LANGUAGE: Не удалось обновить сообщение выбора языка: {error}")
await callback.message.answer(
texts.t("LANGUAGE_SELECTED", "🌐 Язык интерфейса обновлен."),
)
await callback.answer()
await _continue_registration_after_language(
message=None,
callback=callback,
state=state,
db=db,
)
async def process_rules_accept(
callback: types.CallbackQuery,
state: FSMContext,
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession
):
@@ -1069,8 +1236,9 @@ def get_referral_code_keyboard(language: str):
async def get_main_menu_text(user, texts, db: AsyncSession):
import html
base_text = texts.MAIN_MENU.format(
user_name=user.full_name,
user_name=html.escape(user.full_name or ""),
subscription_status=_get_subscription_status(user, texts)
)
@@ -1088,8 +1256,9 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
import html
base_text = texts.MAIN_MENU.format(
user_name=user_name,
user_name=html.escape(user_name or ""),
subscription_status=_get_subscription_status_simple(texts)
)
@@ -1141,6 +1310,34 @@ async def required_sub_channel_check(
show_alert=True,
)
if user and user.subscription:
subscription = user.subscription
if (
subscription.is_trial
and subscription.status == SubscriptionStatus.DISABLED.value
):
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(
"✅ Триальная подписка пользователя %s восстановлена после подтверждения подписки на канал",
user.telegram_id,
)
try:
subscription_service = SubscriptionService()
if user.remnawave_uuid:
await subscription_service.update_remnawave_user(db, subscription)
else:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as api_error:
logger.error(
"❌ Ошибка обновления RemnaWave при восстановлении подписки пользователя %s: %s",
user.telegram_id if user else query.from_user.id,
api_error,
)
await query.answer(
texts.t("CHANNEL_SUBSCRIBE_THANKS", "✅ Спасибо за подписку"),
show_alert=True,
@@ -1262,7 +1459,14 @@ def register_handlers(dp: Dispatcher):
StateFilter(RegistrationStates.waiting_for_rules_accept)
)
logger.info("✅ Зарегистрирован process_rules_accept")
dp.callback_query.register(
process_language_selection,
F.data.startswith("language_select:"),
StateFilter(RegistrationStates.waiting_for_language)
)
logger.info("✅ Зарегистрирован process_language_selection")
dp.callback_query.register(
process_referral_code_skip,
F.data == "referral_skip",
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User, Ticket, TicketStatus
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.database.crud.user import get_user_by_id
from app.keyboards.inline import (
get_ticket_cancel_keyboard,
get_my_tickets_keyboard,
@@ -937,10 +938,21 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
if len(title) > 60:
title = title[:57] + "..."
# Загрузим пользователя, чтобы отобразить реальный Telegram ID и username
try:
user = await get_user_by_id(db, ticket.user_id)
except Exception:
user = None
full_name = user.full_name if user else "Unknown"
telegram_id_display = user.telegram_id if user else ""
username_display = (user.username or "отсутствует") if user else "отсутствует"
notification_text = (
f"🎫 <b>НОВЫЙ ТИКЕТ</b>\n\n"
f"🆔 <b>ID:</b> <code>{ticket.id}</code>\n"
f"👤 <b>User ID:</b> <code>{ticket.user_id}</code>\n"
f"👤 <b>Пользователь:</b> {full_name}\n"
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
f"📱 <b>Username:</b> @{username_display}\n"
f"📝 <b>Заголовок:</b> {title or ''}\n"
f"📅 <b>Создан:</b> {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
)
+3
View File
@@ -99,6 +99,9 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM
InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"),
InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring")
],
[
InlineKeyboardButton(text="🧩 Конфигурация бота", callback_data="admin_bot_config"),
],
[
InlineKeyboardButton(
text=texts.t("ADMIN_MONITORING_SETTINGS", "⚙️ Настройки мониторинга"),
+309 -44
View File
@@ -8,11 +8,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.utils.pricing_utils import format_period_description
from app.utils.pricing_utils import format_period_description, apply_percentage_discount
from app.utils.subscription_utils import (
get_display_subscription_link,
get_happ_cryptolink_redirect_link,
)
import logging
logger = logging.getLogger(__name__)
_LANGUAGE_DISPLAY_NAMES = {
"ru": "🇷🇺 Русский",
"en": "🇬🇧 English",
}
def get_rules_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
@@ -58,6 +67,49 @@ def get_post_registration_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKe
])
def get_language_selection_keyboard(
current_language: Optional[str] = None,
*,
include_back: bool = False,
language: str = DEFAULT_LANGUAGE,
) -> InlineKeyboardMarkup:
available_languages = settings.get_available_languages()
buttons: List[List[InlineKeyboardButton]] = []
row: List[InlineKeyboardButton] = []
normalized_current = (current_language or "").lower()
for index, lang_code in enumerate(available_languages, start=1):
normalized_code = lang_code.lower()
display_name = _LANGUAGE_DISPLAY_NAMES.get(
normalized_code,
normalized_code.upper(),
)
prefix = "" if normalized_code == normalized_current and normalized_current else ""
row.append(
InlineKeyboardButton(
text=f"{prefix}{display_name}",
callback_data=f"language_select:{normalized_code}",
)
)
if len(row) == 2:
buttons.append(row)
row = []
if row:
buttons.append(row)
if include_back:
texts = get_texts(language)
buttons.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_main_menu_keyboard(
language: str = DEFAULT_LANGUAGE,
is_admin: bool = False,
@@ -87,7 +139,7 @@ def get_main_menu_keyboard(
if has_active_subscription and subscription_is_active:
connect_mode = settings.CONNECT_BUTTON_MODE
subscription_url = getattr(subscription, "subscription_url", None)
subscription_link = get_display_subscription_link(subscription)
def _fallback_connect_button() -> InlineKeyboardButton:
return InlineKeyboardButton(
@@ -96,11 +148,11 @@ def get_main_menu_keyboard(
)
if connect_mode == "miniapp_subscription":
if subscription_url:
if subscription_link:
keyboard.append([
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=subscription_url)
web_app=types.WebAppInfo(url=subscription_link)
)
])
else:
@@ -113,11 +165,21 @@ def get_main_menu_keyboard(
)
])
elif connect_mode == "link":
if subscription_url:
if subscription_link:
keyboard.append([
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
url=subscription_url
url=subscription_link
)
])
else:
keyboard.append([_fallback_connect_button()])
elif connect_mode == "happ_cryptolink":
if subscription_link:
keyboard.append([
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="open_subscription_link",
)
])
else:
@@ -125,6 +187,10 @@ def get_main_menu_keyboard(
else:
keyboard.append([_fallback_connect_button()])
happ_row = get_happ_download_button_row(texts)
if happ_row:
keyboard.append(happ_row)
keyboard.append([
InlineKeyboardButton(text=balance_button_text, callback_data="menu_balance"),
InlineKeyboardButton(text=texts.MENU_SUBSCRIPTION, callback_data="menu_subscription")
@@ -206,6 +272,11 @@ def get_main_menu_keyboard(
support_row.append(InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"))
support_row.append(InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules"))
keyboard.append(support_row)
if settings.is_language_selection_enabled():
keyboard.append([
InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data="menu_language")
])
if settings.DEBUG:
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
@@ -227,6 +298,95 @@ def get_main_menu_keyboard(
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_happ_download_button_row(texts) -> Optional[List[InlineKeyboardButton]]:
if not settings.is_happ_download_button_enabled():
return None
return [
InlineKeyboardButton(
text=texts.t("HAPP_DOWNLOAD_BUTTON", "⬇️ Скачать Happ"),
callback_data="subscription_happ_download"
)
]
def get_happ_cryptolink_keyboard(
subscription_link: str,
language: str = DEFAULT_LANGUAGE,
redirect_link: Optional[str] = None,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
final_redirect_link = redirect_link or get_happ_cryptolink_redirect_link(subscription_link)
buttons: List[List[InlineKeyboardButton]] = []
if final_redirect_link:
buttons.append([
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
url=final_redirect_link,
)
])
buttons.extend([
[
InlineKeyboardButton(
text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"),
callback_data="happ_download_ios",
)
],
[
InlineKeyboardButton(
text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"),
callback_data="happ_download_android",
)
],
[
InlineKeyboardButton(
text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"),
callback_data="happ_download_macos",
)
],
[
InlineKeyboardButton(
text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"),
callback_data="happ_download_windows",
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu",
)
],
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_happ_download_platform_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
buttons = [
[InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_IOS", "🍎 iOS"), callback_data="happ_download_ios")],
[InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_ANDROID", "🤖 Android"), callback_data="happ_download_android")],
[InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_MACOS", "🖥️ Mac OS"), callback_data="happ_download_macos")],
[InlineKeyboardButton(text=texts.t("HAPP_PLATFORM_WINDOWS", "💻 Windows"), callback_data="happ_download_windows")],
[InlineKeyboardButton(text=texts.BACK, callback_data="happ_download_close")],
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_happ_download_link_keyboard(language: str, link: str) -> InlineKeyboardMarkup:
texts = get_texts(language)
buttons = [
[InlineKeyboardButton(text=texts.t("HAPP_DOWNLOAD_OPEN_LINK", "🔗 Открыть ссылку"), url=link)],
[InlineKeyboardButton(text=texts.BACK, callback_data="happ_download_back")],
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_back_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
@@ -285,28 +445,31 @@ def get_insufficient_balance_keyboard(
texts = get_texts(language)
keyboard = get_payment_methods_keyboard(amount_kopeks or 0, language)
if resume_callback:
keyboard.inline_keyboard.insert(
0,
[
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data=resume_callback,
)
],
)
back_row_index: int | None = None
if keyboard.inline_keyboard:
last_row = keyboard.inline_keyboard[-1]
if (
len(last_row) == 1
and isinstance(last_row[0], InlineKeyboardButton)
and last_row[0].callback_data == "menu_balance"
and last_row[0].callback_data in {"menu_balance", "back_to_menu"}
):
keyboard.inline_keyboard[-1][0] = InlineKeyboardButton(
text=last_row[0].text,
text=texts.t("PAYMENT_RETURN_HOME_BUTTON", "🏠 На главную"),
callback_data="back_to_menu",
)
back_row_index = len(keyboard.inline_keyboard) - 1
if resume_callback:
return_row = [
InlineKeyboardButton(
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
callback_data=resume_callback,
)
]
insert_index = back_row_index if back_row_index is not None else len(keyboard.inline_keyboard)
keyboard.inline_keyboard.insert(insert_index, return_row)
return keyboard
@@ -323,14 +486,15 @@ def get_subscription_keyboard(
keyboard = []
if has_subscription:
if subscription and subscription.subscription_url:
subscription_link = get_display_subscription_link(subscription) if subscription else None
if subscription_link:
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
keyboard.append([
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=subscription.subscription_url)
web_app=types.WebAppInfo(url=subscription_link)
)
])
elif connect_mode == "miniapp_custom":
@@ -347,12 +511,34 @@ def get_subscription_keyboard(
])
elif connect_mode == "link":
keyboard.append([
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription.subscription_url)
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), url=subscription_link)
])
elif connect_mode == "happ_cryptolink":
keyboard.append([
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="open_subscription_link",
)
])
else:
keyboard.append([
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")
])
elif settings.CONNECT_BUTTON_MODE == "miniapp_custom":
keyboard.append([
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL)
)
])
else:
keyboard.append([
InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")
])
happ_row = get_happ_download_button_row(texts)
if happ_row:
keyboard.append(happ_row)
if not is_trial:
keyboard.append([
@@ -699,7 +885,7 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
if settings.is_pal24_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CARD_PAL24", "💳 Банковская карта (PayPalych)"),
text=texts.t("PAYMENT_CARD_PAL24", "🏦 СБП (PayPalych)"),
callback_data=_build_callback("pal24")
)
])
@@ -993,7 +1179,11 @@ def get_extend_subscription_keyboard(language: str = DEFAULT_LANGUAGE) -> Inline
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
def get_add_traffic_keyboard(
language: str = DEFAULT_LANGUAGE,
subscription_end_date: datetime = None,
discount_percent: int = 0,
) -> InlineKeyboardMarkup:
from app.utils.pricing_utils import get_remaining_months
from app.config import settings
texts = get_texts(language)
@@ -1025,8 +1215,13 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_
for package in enabled_packages:
gb = package['gb']
price_per_month = package['price']
total_price = price_per_month * months_multiplier
discounted_per_month, discount_per_month = apply_percentage_discount(
price_per_month,
discount_percent,
)
total_price = discounted_per_month * months_multiplier
total_discount = discount_per_month * months_multiplier
if gb == 0:
if language == "ru":
text = f"♾️ Безлимитный трафик - {total_price//100}{period_text}"
@@ -1037,7 +1232,10 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_
text = f"📊 +{gb} ГБ трафика - {total_price//100}{period_text}"
else:
text = f"📊 +{gb} GB traffic - {total_price//100}{period_text}"
if discount_percent > 0 and total_discount > 0:
text += f" (скидка {discount_percent}%: -{total_discount//100}₽)"
buttons.append([
InlineKeyboardButton(text=text, callback_data=f"add_traffic_{gb}")
])
@@ -1051,7 +1249,12 @@ def get_add_traffic_keyboard(language: str = DEFAULT_LANGUAGE, subscription_end_
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LANGUAGE, subscription_end_date: datetime = None) -> InlineKeyboardMarkup:
def get_change_devices_keyboard(
current_devices: int,
language: str = DEFAULT_LANGUAGE,
subscription_end_date: datetime = None,
discount_percent: int = 0,
) -> InlineKeyboardMarkup:
from app.utils.pricing_utils import get_remaining_months
from app.config import settings
texts = get_texts(language)
@@ -1088,8 +1291,17 @@ def get_change_devices_keyboard(current_devices: int, language: str = DEFAULT_LA
if chargeable_devices > 0:
price_per_month = chargeable_devices * device_price_per_month
total_price = price_per_month * months_multiplier
discounted_per_month, discount_per_month = apply_percentage_discount(
price_per_month,
discount_percent,
)
total_price = discounted_per_month * months_multiplier
price_text = f" (+{total_price//100}{period_text})"
if discount_percent > 0 and discount_per_month * months_multiplier > 0:
price_text += (
f" (скидка {discount_percent}%:"
f" -{(discount_per_month * months_multiplier)//100}₽)"
)
action_text = ""
else:
price_text = " (бесплатно)"
@@ -1166,7 +1378,8 @@ def get_manage_countries_keyboard(
selected: List[str],
current_subscription_countries: List[str],
language: str = DEFAULT_LANGUAGE,
subscription_end_date: datetime = None
subscription_end_date: datetime = None,
discount_percent: int = 0,
) -> InlineKeyboardMarkup:
from app.utils.pricing_utils import get_remaining_months
@@ -1181,10 +1394,18 @@ def get_manage_countries_keyboard(
total_cost = 0
for country in countries:
if not country.get('is_available', True):
continue
uuid = country['uuid']
name = country['name']
price_per_month = country['price_kopeks']
discounted_per_month, discount_per_month = apply_percentage_discount(
price_per_month,
discount_percent,
)
if uuid in current_subscription_countries:
if uuid in selected:
icon = ""
@@ -1193,17 +1414,31 @@ def get_manage_countries_keyboard(
else:
if uuid in selected:
icon = ""
total_cost += price_per_month * months_multiplier
total_cost += discounted_per_month * months_multiplier
else:
icon = ""
if uuid not in current_subscription_countries and uuid in selected:
total_price = price_per_month * months_multiplier
total_price = discounted_per_month * months_multiplier
if months_multiplier > 1:
price_text = f" ({price_per_month//100}₽/мес × {months_multiplier} = {total_price//100}₽)"
logger.info(f"🔍 Сервер {name}: {price_per_month/100}₽/мес × {months_multiplier} мес = {total_price/100}")
price_text = (
f" ({discounted_per_month//100}₽/мес × {months_multiplier} = {total_price//100})"
)
logger.info(
"🔍 Сервер %s: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)",
name,
discounted_per_month / 100,
months_multiplier,
total_price / 100,
(discount_per_month * months_multiplier) / 100,
)
else:
price_text = f" ({total_price//100}₽)"
if discount_percent > 0 and discount_per_month * months_multiplier > 0:
price_text += (
f" (скидка {discount_percent}%:"
f" -{(discount_per_month * months_multiplier)//100}₽)"
)
display_name = f"{icon} {name}{price_text}"
else:
display_name = f"{icon} {name}"
@@ -1288,9 +1523,18 @@ def get_connection_guide_keyboard(
if app_buttons:
keyboard.append(app_buttons)
keyboard.append([
InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url)
])
if settings.is_happ_cryptolink_mode():
copy_button = InlineKeyboardButton(
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
callback_data="open_subscription_link",
)
else:
copy_button = InlineKeyboardButton(
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
url=subscription_url,
)
keyboard.append([copy_button])
keyboard.extend([
[
@@ -1361,9 +1605,18 @@ def get_specific_app_keyboard(
if app_buttons:
keyboard.append(app_buttons)
keyboard.append([
InlineKeyboardButton(text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"), url=subscription_url)
])
if settings.is_happ_cryptolink_mode():
copy_button = InlineKeyboardButton(
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
callback_data="open_subscription_link",
)
else:
copy_button = InlineKeyboardButton(
text=texts.t("COPY_SUBSCRIPTION_LINK", "📋 Скопировать ссылку подписки"),
url=subscription_url,
)
keyboard.append([copy_button])
if 'additionalAfterAddSubscriptionStep' in app and 'buttons' in app['additionalAfterAddSubscriptionStep']:
for button in app['additionalAfterAddSubscriptionStep']['buttons']:
@@ -1725,10 +1978,22 @@ def get_admin_tickets_keyboard(
status_emoji = ticket.get('status_emoji', '')
if ticket.get('is_closed', False):
status_emoji = ''
user_name = ticket.get('user_name', 'Unknown')[:15]
user_name = ticket.get('user_name', 'Unknown')
username = ticket.get('username')
telegram_id = ticket.get('telegram_id')
# Сформируем компактное отображение: Имя (@username | ID)
name_parts = [user_name[:15]]
contact_parts = []
if username:
contact_parts.append(f"@{username}")
if telegram_id:
contact_parts.append(str(telegram_id))
if contact_parts:
name_parts.append(f"({' | '.join(contact_parts)})")
name_display = ' '.join(name_parts)
title = ticket.get('title', 'Без названия')[:20]
locked_emoji = ticket.get('locked_emoji', '')
button_text = f"{status_emoji} #{ticket['id']} {locked_emoji} {user_name}: {title}".replace(" ", " ")
button_text = f"{status_emoji} #{ticket['id']} {locked_emoji} {name_display}: {title}".replace(" ", " ")
row = [InlineKeyboardButton(text=button_text, callback_data=f"admin_view_ticket_{ticket['id']}")]
if ticket.get('is_closed', False):
closed_rows.append(row)
+24 -1
View File
@@ -19,6 +19,16 @@
"CONFIRM": "✅ Confirm",
"CONFIRM_CHANGE_BUTTON": "✅ Confirm change",
"CONNECT_BUTTON": "🔗 Connect",
"HAPP_DOWNLOAD_BUTTON": "⬇️ Download Happ",
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Download Happ</b>\nChoose your device:",
"HAPP_PLATFORM_IOS": "🍎 iOS",
"HAPP_PLATFORM_ANDROID": "🤖 Android",
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
"HAPP_PLATFORM_PC": "💻 PC",
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Download Happ for {platform}:",
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Download link for this device is not configured",
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Open link",
"CONTINUE": "➡️ Continue",
"CONTINUE_BUTTON": "➡️ Continue",
"COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link",
@@ -38,6 +48,7 @@
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Insufficient funds</b>\n\nService price: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method. The amount will be filled in automatically.",
"LANGUAGE_PROMPT": "🌐 Choose the interface language:",
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
"LOADING": "⏳ Loading...",
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
@@ -127,7 +138,16 @@
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Promo group discounts:",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Servers: {percent}%",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Traffic: {percent}%",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Devices: {percent}%",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Add-on discounts: <b>enabled</b>",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Add-on discounts: <b>disabled</b>",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Enable add-on discounts",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Disable add-on discounts",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Add-on purchase discounts are <b>enabled</b>.",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Add-on purchase discounts are <b>disabled</b>.",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
@@ -311,7 +331,9 @@
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "️ The subscription link is available via the buttons below or in the “My subscription” section.",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Connect subscription</b>\n\n️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
@@ -393,6 +415,7 @@
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 Main menu",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
+24 -1
View File
@@ -14,7 +14,16 @@
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
"ADMIN_PROMO_GROUP_DISCOUNTS_HEADER": "💸 Скидки промогруппы:",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_SERVERS": "• Серверы: {percent}%",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_TRAFFIC": "• Трафик: {percent}%",
"ADMIN_PROMO_GROUP_DISCOUNT_LINE_DEVICES": "• Устройства: {percent}%",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_ENABLED": "🧩 Скидки на доп. услуги: <b>включены</b>",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_DISABLED": "🧩 Скидки на доп. услуги: <b>отключены</b>",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_ENABLE": "🧩 Включить скидки на доп. услуги",
"ADMIN_PROMO_GROUP_TOGGLE_ADDON_DISCOUNT_DISABLE": "🧩 Отключить скидки на доп. услуги",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_ENABLED": "🧩 Скидки на докупку доп. услуг <b>включены</b>.",
"ADMIN_PROMO_GROUP_ADDON_DISCOUNT_UPDATED_DISABLED": "🧩 Скидки на докупку доп. услуг <b>отключены</b>.",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
@@ -99,6 +108,16 @@
"CONFIRM": "✅ Подтвердить",
"CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение",
"CONNECT_BUTTON": "🔗 Подключиться",
"HAPP_DOWNLOAD_BUTTON": "⬇️ Скачать Happ",
"HAPP_DOWNLOAD_PROMPT": "📥 <b>Скачать Happ</b>\nВыберите ваше устройство:",
"HAPP_PLATFORM_IOS": "🍎 iOS",
"HAPP_PLATFORM_ANDROID": "🤖 Android",
"HAPP_PLATFORM_MACOS": "🖥️ Mac OS",
"HAPP_PLATFORM_WINDOWS": "💻 Windows",
"HAPP_PLATFORM_PC": "💻 ПК",
"HAPP_DOWNLOAD_LINK_MESSAGE": "⬇️ Скачайте Happ для {platform}:",
"HAPP_DOWNLOAD_LINK_NOT_SET": "❌ Ссылка для этого устройства не настроена",
"HAPP_DOWNLOAD_OPEN_LINK": "🔗 Открыть ссылку",
"CONTACT_SUPPORT": "💬 Написать в поддержку",
"CONTINUE": "➡️ Продолжить",
"CONTINUE_BUTTON": "✅ Продолжить",
@@ -125,6 +144,7 @@
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
"ADDON_INSUFFICIENT_FUNDS_MESSAGE": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость услуги: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения. Сумма подставится автоматически.",
"INVALID_AMOUNT": "❌ Неверная сумма",
"LANGUAGE_PROMPT": "🌐 Выберите язык интерфейса:",
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
"LOADING": "⏳ Загрузка...",
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
@@ -313,7 +333,9 @@
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 <b>Подключить подписку</b>\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
@@ -395,6 +417,7 @@
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
"PAYMENT_RETURN_HOME_BUTTON": "🏠 На главную",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
+13 -6
View File
@@ -12,6 +12,7 @@ from app.database.crud.user import get_user_by_telegram_id, create_user
from app.services.remnawave_service import RemnaWaveService
from app.states import RegistrationStates
from app.utils.check_reg_process import is_registration_process
from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
@@ -23,7 +24,7 @@ async def _refresh_remnawave_description(
) -> None:
try:
remnawave_service = RemnaWaveService()
async with remnawave_service.api as api:
async with remnawave_service.get_api_client() as api:
await api.update_user(uuid=remnawave_uuid, description=description)
logger.info(
f"✅ [Middleware] Описание пользователя {telegram_id} обновлено в RemnaWave"
@@ -115,6 +116,7 @@ class AuthMiddleware(BaseMiddleware):
current_state = await state.get_state()
registration_states = [
RegistrationStates.waiting_for_language.state,
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
@@ -125,7 +127,10 @@ class AuthMiddleware(BaseMiddleware):
or (
isinstance(event, CallbackQuery)
and event.data
and (event.data in ['rules_accept', 'rules_decline', 'referral_skip'])
and (
event.data in ['rules_accept', 'rules_decline', 'referral_skip']
or event.data.startswith('language_select:')
)
)
)
@@ -158,15 +163,17 @@ class AuthMiddleware(BaseMiddleware):
logger.info(f"🔄 [Middleware] Username обновлен для {user.id}: '{old_username}''{db_user.username}'")
profile_updated = True
if db_user.first_name != user.first_name:
safe_first = sanitize_telegram_name(user.first_name)
safe_last = sanitize_telegram_name(user.last_name)
if db_user.first_name != safe_first:
old_first_name = db_user.first_name
db_user.first_name = user.first_name
db_user.first_name = safe_first
logger.info(f"🔄 [Middleware] Имя обновлено для {user.id}: '{old_first_name}''{db_user.first_name}'")
profile_updated = True
if db_user.last_name != user.last_name:
if db_user.last_name != safe_last:
old_last_name = db_user.last_name
db_user.last_name = user.last_name
db_user.last_name = safe_last
logger.info(f"🔄 [Middleware] Фамилия обновлена для {user.id}: '{old_last_name}''{db_user.last_name}'")
profile_updated = True
+58 -3
View File
@@ -7,10 +7,15 @@ from aiogram.types import TelegramObject, Update, Message, CallbackQuery
from aiogram.enums import ChatMemberStatus
from app.config import settings
from app.database.database import get_db
from app.database.crud.subscription import deactivate_subscription
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import SubscriptionStatus
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.utils.check_reg_process import is_registration_process
from app.services.subscription_service import SubscriptionService
logger = logging.getLogger(__name__)
@@ -95,11 +100,14 @@ class ChannelCheckerMiddleware(BaseMiddleware):
return await handler(event, data)
elif member.status in self.BAD_MEMBER_STATUS:
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
if telegram_id:
await self._deactivate_trial_subscription(telegram_id)
if isinstance(event, CallbackQuery) and event.data == "sub_channel_check":
await event.answer("❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.", show_alert=True)
return
return
return await self._deny_message(event, bot, channel_link)
else:
logger.warning(f"⚠️ Неожиданный статус пользователя {telegram_id}: {member.status}")
@@ -120,6 +128,53 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.error(f"❌ Неожиданная ошибка при проверке подписки: {e}")
return await handler(event, data)
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
async for db in get_db():
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user or not user.subscription:
logger.debug(
"⚠️ Пользователь %s отсутствует или не имеет подписки — пропускаем деактивацию",
telegram_id,
)
break
subscription = user.subscription
if (not subscription.is_trial or
subscription.status != SubscriptionStatus.ACTIVE.value):
logger.debug(
"ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)",
telegram_id,
subscription.is_trial,
subscription.status,
)
break
await deactivate_subscription(db, subscription)
logger.info(
"🚫 Триальная подписка пользователя %s отключена после отписки от канала",
telegram_id,
)
if user.remnawave_uuid:
service = SubscriptionService()
try:
await service.disable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
"❌ Не удалось отключить пользователя RemnaWave %s: %s",
user.remnawave_uuid,
api_error,
)
except Exception as db_error:
logger.error(
"❌ Ошибка деактивации подписки пользователя %s после отписки: %s",
telegram_id,
db_error,
)
finally:
break
@staticmethod
async def _deny_message(event: TelegramObject, bot: Bot, channel_link: str):
logger.debug("🚫 Отправляем сообщение о необходимости подписки")
+407 -54
View File
@@ -7,8 +7,17 @@ from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User, Subscription, Transaction, TransactionType
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.user import get_user_by_id
from app.database.models import (
AdvertisingCampaign,
PromoCodeType,
PromoGroup,
Subscription,
Transaction,
TransactionType,
User,
)
logger = logging.getLogger(__name__)
@@ -25,20 +34,141 @@ class AdminNotificationService:
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
if not referred_by_id:
return "Нет"
try:
referrer = await get_user_by_id(db, referred_by_id)
if not referrer:
return f"ID {referred_by_id} (не найден)"
if referrer.username:
return f"@{referrer.username} (ID: {referred_by_id})"
else:
return f"ID {referrer.telegram_id}"
except Exception as e:
logger.error(f"Ошибка получения данных рефера {referred_by_id}: {e}")
return f"ID {referred_by_id}"
async def _get_user_promo_group(self, db: AsyncSession, user: User) -> Optional[PromoGroup]:
if getattr(user, "promo_group", None):
return user.promo_group
if not user.promo_group_id:
return None
try:
await db.refresh(user, attribute_names=["promo_group"])
except Exception:
# relationship might not be available — fallback to direct fetch
pass
if getattr(user, "promo_group", None):
return user.promo_group
try:
return await get_promo_group_by_id(db, user.promo_group_id)
except Exception as e:
logger.error(
"Ошибка загрузки промогруппы %s пользователя %s: %s",
user.promo_group_id,
user.telegram_id,
e,
)
return None
def _format_promo_group_discounts(self, promo_group: PromoGroup) -> List[str]:
discount_lines: List[str] = []
discount_map = {
"servers": ("Серверы", promo_group.server_discount_percent),
"traffic": ("Трафик", promo_group.traffic_discount_percent),
"devices": ("Устройства", promo_group.device_discount_percent),
}
for _, (title, percent) in discount_map.items():
if percent and percent > 0:
discount_lines.append(f"{title}: -{percent}%")
period_discounts_raw = promo_group.period_discounts or {}
period_items: List[tuple[int, int]] = []
if isinstance(period_discounts_raw, dict):
for raw_days, raw_percent in period_discounts_raw.items():
try:
days = int(raw_days)
percent = int(raw_percent)
except (TypeError, ValueError):
continue
if percent > 0:
period_items.append((days, percent))
period_items.sort(key=lambda item: item[0])
if period_items:
formatted_periods = ", ".join(
f"{days} д. — -{percent}%" for days, percent in period_items
)
discount_lines.append(f"• Периоды: {formatted_periods}")
if promo_group.apply_discounts_to_addons:
discount_lines.append("• Доп. услуги: ✅ скидка действует")
else:
discount_lines.append("• Доп. услуги: ❌ без скидки")
return discount_lines
def _format_promo_group_block(
self,
promo_group: Optional[PromoGroup],
*,
title: str = "Промогруппа",
icon: str = "🏷️",
) -> str:
if not promo_group:
return f"{icon} <b>{title}:</b> —"
lines = [f"{icon} <b>{title}:</b> {promo_group.name}"]
discount_lines = self._format_promo_group_discounts(promo_group)
if discount_lines:
lines.append("💸 <b>Скидки:</b>")
lines.extend(discount_lines)
else:
lines.append("💸 <b>Скидки:</b> отсутствуют")
return "\n".join(lines)
def _get_promocode_type_display(self, promo_type: Optional[str]) -> str:
mapping = {
PromoCodeType.BALANCE.value: "💰 Бонус на баланс",
PromoCodeType.SUBSCRIPTION_DAYS.value: "⏰ Доп. дни подписки",
PromoCodeType.TRIAL_SUBSCRIPTION.value: "🎁 Триал подписка",
}
if not promo_type:
return "️ Не указан"
return mapping.get(promo_type, f"{promo_type}")
def _format_campaign_bonus(self, campaign: AdvertisingCampaign) -> List[str]:
if campaign.is_balance_bonus:
return [
f"💰 Баланс: {settings.format_price(campaign.balance_bonus_kopeks or 0)}",
]
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}",
]
if campaign.subscription_squads:
details.append(f"🌐 Сквады: {len(campaign.subscription_squads)} шт.")
return details
return ["ℹ️ Бонусы не предусмотрены"]
async def send_trial_activation_notification(
self,
@@ -52,7 +182,9 @@ class AdminNotificationService:
try:
user_status = "🆕 Новый" if not user.has_had_paid_subscription else "🔄 Существующий"
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -60,6 +192,8 @@ class AdminNotificationService:
📱 <b>Username:</b> @{user.username or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
{promo_block}
<b>Параметры триала:</b>
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
📊 Трафик: {settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ
@@ -102,7 +236,9 @@ class AdminNotificationService:
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method)
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
message = f"""💎 <b>{event_type}</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -110,6 +246,8 @@ class AdminNotificationService:
📱 <b>Username:</b> @{user.username or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
{promo_block}
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -214,7 +352,7 @@ class AdminNotificationService:
) -> bool:
if not self._is_enabled():
return False
try:
deposit_count_result = await db.execute(
select(func.count())
@@ -230,7 +368,14 @@ class AdminNotificationService:
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
subscription_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = subscription_result.scalar_one_or_none()
subscription_status = self._get_subscription_status(subscription)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
message = f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
👤 <b>Пользователь:</b> {user.full_name}
@@ -238,6 +383,8 @@ class AdminNotificationService:
📱 <b>Username:</b> @{user.username or 'отсутствует'}
💳 <b>Статус:</b> {topup_status}
{promo_block}
💰 <b>Детали пополнения:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -249,7 +396,7 @@ class AdminNotificationService:
Изменение: +{settings.format_price(balance_change)}
🔗 <b>Реферер:</b> {referrer_info}
📱 <b>Подписка:</b> {self._get_subscription_status(user)}
📱 <b>Подписка:</b> {subscription_status}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
@@ -266,21 +413,31 @@ class AdminNotificationService:
subscription: Subscription,
transaction: Transaction,
extended_days: int,
old_end_date: datetime
old_end_date: datetime,
*,
new_end_date: datetime | None = None,
balance_after: int | None = None,
) -> bool:
if not self._is_enabled():
return False
try:
payment_method = self._get_payment_method_display(transaction.payment_method)
servers_info = await self._get_servers_info(subscription.connected_squads)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
current_end_date = new_end_date or subscription.end_date
current_balance = balance_after if balance_after is not None else user.balance_kopeks
message = f"""⏰ <b>ПРОДЛЕНИЕ ПОДПИСКИ</b>
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
{promo_block}
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
@@ -289,23 +446,206 @@ class AdminNotificationService:
📅 <b>Продление:</b>
Добавлено дней: {extended_days}
📆 Было до: {old_end_date.strftime('%d.%m.%Y %H:%M')}
📆 Стало до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
📆 Стало до: {current_end_date.strftime('%d.%m.%Y %H:%M')}
📱 <b>Текущие параметры:</b>
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
📱 Устройства: {subscription.device_limit}
🌐 Серверы: {servers_info}
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
💰 <b>Баланс после операции:</b> {settings.format_price(current_balance)}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
return False
async def send_promocode_activation_notification(
self,
db: AsyncSession,
user: User,
promocode_data: Dict[str, Any],
effect_description: str,
) -> bool:
if not self._is_enabled():
return False
try:
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
type_display = self._get_promocode_type_display(promocode_data.get("type"))
usage_info = f"{promocode_data.get('current_uses', 0)}/{promocode_data.get('max_uses', 0)}"
message_lines = [
"🎫 <b>АКТИВАЦИЯ ПРОМОКОДА</b>",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
promo_block,
"",
"🎟️ <b>Промокод:</b>",
f"🔖 Код: <code>{promocode_data.get('code')}</code>",
f"🧾 Тип: {type_display}",
f"📊 Использования: {usage_info}",
]
balance_bonus = promocode_data.get("balance_bonus_kopeks", 0)
if balance_bonus:
message_lines.append(
f"💰 Бонус на баланс: {settings.format_price(balance_bonus)}"
)
subscription_days = promocode_data.get("subscription_days", 0)
if subscription_days:
message_lines.append(f"📅 Доп. дни подписки: {subscription_days}")
valid_until = promocode_data.get("valid_until")
if valid_until:
message_lines.append(
f"⏳ Действует до: {valid_until.strftime('%d.%m.%Y %H:%M')}"
if isinstance(valid_until, datetime)
else f"⏳ Действует до: {valid_until}"
)
message_lines.extend(
[
"",
"📝 <b>Эффект:</b>",
effect_description.strip() or "✅ Промокод активирован",
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления об активации промокода: {e}")
return False
async def send_campaign_link_visit_notification(
self,
db: AsyncSession,
telegram_user: types.User,
campaign: AdvertisingCampaign,
user: Optional[User] = None,
) -> bool:
if not self._is_enabled():
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 "отсутствует"
message_lines = [
"📣 <b>ПЕРЕХОД ПО РЕКЛАМНОЙ КАМПАНИИ</b>",
"",
f"🧾 <b>Кампания:</b> {campaign.name}",
f"🆔 ID кампании: {campaign.id}",
f"🔗 Start-параметр: <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>",
]
bonus_lines = self._format_campaign_bonus(campaign)
message_lines.extend(bonus_lines)
message_lines.extend(
[
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления о переходе по кампании: {e}")
return False
async def send_user_promo_group_change_notification(
self,
db: AsyncSession,
user: User,
old_group: Optional[PromoGroup],
new_group: PromoGroup,
*,
reason: Optional[str] = None,
initiator: Optional[User] = None,
automatic: bool = False,
) -> bool:
if not self._is_enabled():
return False
try:
title = "🤖 АВТОМАТИЧЕСКАЯ СМЕНА ПРОМОГРУППЫ" if automatic else "👥 СМЕНА ПРОМОГРУППЫ"
initiator_line = None
if initiator:
initiator_line = (
f"👮 <b>Инициатор:</b> {initiator.full_name} (ID: {initiator.telegram_id})"
)
elif automatic:
initiator_line = "🤖 Автоматическое назначение"
message_lines = [
f"{title}",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
self._format_promo_group_block(new_group, title="Новая промогруппа", icon="🏆"),
]
if old_group and old_group.id != new_group.id:
message_lines.extend(
[
"",
self._format_promo_group_block(
old_group, title="Предыдущая промогруппа", icon="♻️"
),
]
)
if initiator_line:
message_lines.extend(["", initiator_line])
if reason:
message_lines.extend(["", f"📝 Причина: {reason}"])
message_lines.extend(
[
"",
f"💰 Баланс пользователя: {settings.format_price(user.balance_kopeks)}",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления о смене промогруппы: {e}")
return False
async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool:
if not self.chat_id:
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
@@ -353,7 +693,7 @@ class AdminNotificationService:
'yookassa': '💳 YooKassa (карта)',
'tribute': '💎 Tribute (карта)',
'mulenpay': '💳 Mulen Pay (карта)',
'pal24': '💳 PayPalych (карта)',
'pal24': '🏦 PayPalych (СБП)',
'manual': '🛠️ Вручную (админ)',
'balance': '💰 С баланса'
}
@@ -368,15 +708,14 @@ class AdminNotificationService:
return "∞ Безлимит"
return f"{traffic_gb} ГБ"
def _get_subscription_status(self, user: User) -> str:
if not user.subscription:
def _get_subscription_status(self, subscription: Optional[Subscription]) -> str:
if not subscription:
return "❌ Нет подписки"
sub = user.subscription
if sub.is_trial:
return f"🎯 Триал (до {sub.end_date.strftime('%d.%m')})"
elif sub.is_active:
return f"✅ Активна (до {sub.end_date.strftime('%d.%m')})"
if subscription.is_trial:
return f"🎯 Триал (до {subscription.end_date.strftime('%d.%m')})"
elif subscription.is_active:
return f"✅ Активна (до {subscription.end_date.strftime('%d.%m')})"
else:
return "❌ Неактивна"
@@ -744,50 +1083,64 @@ class AdminNotificationService:
try:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
update_types = {
"traffic": ("📊 ИЗМЕНЕНИЕ ТРАФИКА", "трафик"),
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
"devices": ("📱 ИЗМЕНЕНИЕ УСТРОЙСТВ", "количество устройств"),
"servers": ("🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ", "серверы")
}
title, param_name = update_types.get(update_type, ("⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ", "параметры"))
message = f"""{title}
👤 <b>Пользователь:</b> {user.full_name}
🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>
📱 <b>Username:</b> @{user.username or 'отсутствует'}
🔧 <b>Изменение:</b>
📋 Параметр: {param_name}"""
message_lines = [
f"{title}",
"",
f"👤 <b>Пользователь:</b> {user.full_name}",
f"🆔 <b>Telegram ID:</b> <code>{user.telegram_id}</code>",
f"📱 <b>Username:</b> @{user.username or 'отсутствует'}",
"",
promo_block,
"",
"🔧 <b>Изменение:</b>",
f"📋 Параметр: {param_name}",
]
if update_type == "servers":
old_servers_info = await self._format_servers_detailed(old_value)
new_servers_info = await self._format_servers_detailed(new_value)
message += f"""
📉 Было: {old_servers_info}
📈 Стало: {new_servers_info}"""
message_lines.extend(
[
f"📉 Было: {old_servers_info}",
f"📈 Стало: {new_servers_info}",
]
)
else:
message += f"""
📉 Было: {self._format_update_value(old_value, update_type)}
📈 Стало: {self._format_update_value(new_value, update_type)}"""
message_lines.extend(
[
f"📉 Было: {self._format_update_value(old_value, update_type)}",
f"📈 Стало: {self._format_update_value(new_value, update_type)}",
]
)
if price_paid > 0:
message += f"\n💰 Доплачено: {settings.format_price(price_paid)}"
message_lines.append(f"💰 Доплачено: {settings.format_price(price_paid)}")
else:
message += f"\n💸 Бесплатно"
message_lines.append("💸 Бесплатно")
message += f"""
message_lines.extend(
[
"",
f"📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}",
f"💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}",
f"🔗 <b>Рефер:</b> {referrer_info}",
"",
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>",
]
)
📅 <b>Подписка действует до:</b> {subscription.end_date.strftime('%d.%m.%Y %H:%M')}
💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}
🔗 <b>Рефер:</b> {referrer_info}
<i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"""
return await self._send_message(message)
return await self._send_message("\n".join(message_lines))
except Exception as e:
logger.error(f"Ошибка отправки уведомления об изменении подписки: {e}")
+187 -9
View File
@@ -12,6 +12,7 @@ import aiofiles
from aiogram.types import FSInputFile
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from app.config import settings
@@ -23,7 +24,8 @@ from app.database.models import (
ServerSquad, SubscriptionServer, UserMessage, YooKassaPayment,
CryptoBotPayment, WelcomeText, Base, PromoGroup, AdvertisingCampaign,
AdvertisingCampaignRegistration, SupportAuditLog, Ticket, TicketMessage,
MulenPayPayment, Pal24Payment
MulenPayPayment, Pal24Payment, DiscountOffer, WebApiToken,
server_squad_promo_groups
)
logger = logging.getLogger(__name__)
@@ -32,7 +34,7 @@ logger = logging.getLogger(__name__)
@dataclass
class BackupMetadata:
timestamp: str
version: str = "1.1"
version: str = "1.2"
database_type: str = "postgresql"
backup_type: str = "full"
tables_count: int = 0
@@ -83,17 +85,23 @@ class BackupService:
PromoCodeUse,
ReferralEarning,
SentNotification,
DiscountOffer,
BroadcastHistory,
AdvertisingCampaign,
AdvertisingCampaignRegistration,
Ticket,
TicketMessage,
SupportAuditLog,
WebApiToken,
]
if self._settings.include_logs:
self.backup_models_ordered.append(MonitoringLog)
self.association_tables = {
"server_squad_promo_groups": server_squad_promo_groups,
}
def _load_settings(self) -> BackupSettings:
return BackupSettings(
auto_backup_enabled=os.getenv("BACKUP_AUTO_ENABLED", "true").lower() == "true",
@@ -171,6 +179,7 @@ class BackupService:
models_to_backup.append(MonitoringLog)
backup_data = {}
association_data = {}
total_records = 0
async for db in get_db():
@@ -214,7 +223,11 @@ class BackupService:
total_records += len(table_data)
logger.info(f"✅ Экспортировано {len(table_data)} записей из {table_name}")
association_data = await self._export_association_tables(db)
for records in association_data.values():
total_records += len(records)
break
except Exception as e:
logger.error(f"Ошибка при экспорте данных: {e}")
@@ -226,7 +239,7 @@ class BackupService:
timestamp=datetime.utcnow().isoformat(),
database_type="postgresql" if settings.is_postgresql() else "sqlite",
backup_type="full",
tables_count=len(models_to_backup),
tables_count=len(models_to_backup) + len(association_data),
total_records=total_records,
compressed=compress,
created_by=created_by,
@@ -239,10 +252,17 @@ class BackupService:
filename += ".gz"
backup_path = self.backup_dir / filename
file_snapshots = await self._collect_file_snapshots()
backup_structure = {
"metadata": asdict(metadata),
"data": backup_data
"data": backup_data,
"associations": association_data,
"files": file_snapshots,
"config": {
"backup_settings": asdict(self._settings)
}
}
if compress:
@@ -271,7 +291,7 @@ class BackupService:
size_mb = file_size / 1024 / 1024
message = (f"✅ Бекап успешно создан!\n"
f"📁 Файл: {filename}\n"
f"📊 Таблиц: {len(models_to_backup)}\n"
f"📊 Таблиц: {metadata.tables_count}\n"
f"📈 Записей: {total_records:,}\n"
f"💾 Размер: {size_mb:.2f} MB")
@@ -319,6 +339,8 @@ class BackupService:
metadata = backup_structure.get("metadata", {})
backup_data = backup_structure.get("data", {})
association_data = backup_structure.get("associations", {})
file_snapshots = backup_structure.get("files", {})
if not backup_data:
return False, "❌ Файл бекапа не содержит данных"
@@ -377,6 +399,14 @@ class BackupService:
await self._update_user_referrals(db, backup_data)
assoc_tables, assoc_records = await self._restore_association_tables(
db,
association_data,
clear_existing
)
restored_tables += assoc_tables
restored_records += assoc_records
await db.commit()
break
@@ -397,6 +427,11 @@ class BackupService:
if self.bot:
await self._send_backup_notification("restore_success", message)
if file_snapshots:
restored_files = await self._restore_file_snapshots(file_snapshots)
if restored_files:
logger.info(f"📁 Восстановлено файлов конфигурации: {restored_files}")
return True, message
@@ -543,6 +578,97 @@ class BackupService:
return col.name
return None
async def _export_association_tables(self, db: AsyncSession) -> Dict[str, List[Dict[str, Any]]]:
association_data: Dict[str, List[Dict[str, Any]]] = {}
for table_name, table_obj in self.association_tables.items():
try:
logger.info(f"📊 Экспортируем таблицу связей: {table_name}")
result = await db.execute(select(table_obj))
rows = result.mappings().all()
association_data[table_name] = [dict(row) for row in rows]
logger.info(
f"✅ Экспортировано {len(rows)} связей из {table_name}"
)
except Exception as e:
logger.error(f"Ошибка экспорта таблицы связей {table_name}: {e}")
return association_data
async def _restore_association_tables(
self,
db: AsyncSession,
association_data: Dict[str, List[Dict[str, Any]]],
clear_existing: bool
) -> Tuple[int, int]:
if not association_data:
return 0, 0
restored_tables = 0
restored_records = 0
if "server_squad_promo_groups" in association_data:
restored = await self._restore_server_squad_promo_groups(
db,
association_data["server_squad_promo_groups"],
clear_existing
)
restored_tables += 1
restored_records += restored
return restored_tables, restored_records
async def _restore_server_squad_promo_groups(
self,
db: AsyncSession,
records: List[Dict[str, Any]],
clear_existing: bool
) -> int:
if not records:
return 0
if clear_existing:
await db.execute(server_squad_promo_groups.delete())
restored = 0
for record in records:
server_id = record.get("server_squad_id")
promo_id = record.get("promo_group_id")
if server_id is None or promo_id is None:
logger.warning(
"Пропущена некорректная запись server_squad_promo_groups: %s",
record
)
continue
try:
await db.execute(
server_squad_promo_groups.insert().values(
server_squad_id=server_id,
promo_group_id=promo_id
)
)
restored += 1
except IntegrityError:
logger.debug(
"Запись server_squad_promo_groups (%s, %s) уже существует",
server_id,
promo_id
)
except Exception as e:
logger.error(
"Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s",
server_id,
promo_id,
e
)
await db.rollback()
raise e
return restored
async def _restore_table_records(
self,
db: AsyncSession,
@@ -590,17 +716,18 @@ class BackupService:
async def _clear_database_tables(self, db: AsyncSession):
tables_order = [
"server_squad_promo_groups",
"ticket_messages", "tickets", "support_audit_logs",
"advertising_campaign_registrations", "advertising_campaigns",
"subscription_servers", "sent_notifications",
"user_messages", "broadcast_history", "subscription_conversions",
"discount_offers", "user_messages", "broadcast_history", "subscription_conversions",
"referral_earnings", "promocode_uses",
"yookassa_payments", "cryptobot_payments",
"mulenpay_payments", "pal24_payments",
"transactions", "welcome_texts", "subscriptions",
"promocodes", "users", "promo_groups",
"server_squads", "squads", "service_rules",
"system_settings", "monitoring_logs"
"system_settings", "web_api_tokens", "monitoring_logs"
]
for table_name in tables_order:
@@ -610,6 +737,57 @@ class BackupService:
except Exception as e:
logger.warning(f"⚠️ Не удалось очистить таблицу {table_name}: {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, 'r', 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
).isoformat()
}
logger.info(
"📁 Добавлен в бекап файл конфигурации: %s",
path_obj
)
except Exception as e:
logger.error(
"Ошибка чтения файла конфигурации %s: %s",
path_obj,
e
)
return snapshots
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 восстановлен по пути %s", target_path)
except Exception as e:
logger.error("Ошибка восстановления файла %s: %s", target_path, e)
return restored_files
async def get_backup_list(self) -> List[Dict[str, Any]]:
backups = []
+279
View File
@@ -0,0 +1,279 @@
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from app.database.database import AsyncSessionLocal
from app.database.models import BroadcastHistory
from app.handlers.admin.messages import (
create_broadcast_keyboard,
get_custom_users,
get_target_users,
)
logger = logging.getLogger(__name__)
VALID_MEDIA_TYPES = {"photo", "video", "document"}
@dataclass(slots=True)
class BroadcastMediaConfig:
type: str
file_id: str
caption: Optional[str] = None
@dataclass(slots=True)
class BroadcastConfig:
target: str
message_text: str
selected_buttons: list[str]
media: Optional[BroadcastMediaConfig] = None
initiator_name: Optional[str] = None
@dataclass(slots=True)
class _BroadcastTask:
task: asyncio.Task
cancel_event: asyncio.Event
class BroadcastService:
"""Handles broadcast execution triggered from the admin web API."""
def __init__(self) -> None:
self._bot: Optional[Bot] = None
self._tasks: dict[int, _BroadcastTask] = {}
self._lock = asyncio.Lock()
def set_bot(self, bot: Bot) -> None:
self._bot = bot
def is_running(self, broadcast_id: int) -> bool:
task_entry = self._tasks.get(broadcast_id)
return bool(task_entry and not task_entry.task.done())
async def start_broadcast(self, broadcast_id: int, config: BroadcastConfig) -> None:
if self._bot is None:
logger.error("Невозможно запустить рассылку %s: бот не инициализирован", broadcast_id)
await self._mark_failed(broadcast_id)
return
cancel_event = asyncio.Event()
async with self._lock:
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
logger.warning("Рассылка %s уже запущена", broadcast_id)
return
task = asyncio.create_task(
self._run_broadcast(broadcast_id, config, cancel_event),
name=f"broadcast-{broadcast_id}",
)
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
async def request_stop(self, broadcast_id: int) -> bool:
async with self._lock:
task_entry = self._tasks.get(broadcast_id)
if not task_entry:
return False
task_entry.cancel_event.set()
return True
async def _run_broadcast(
self,
broadcast_id: int,
config: BroadcastConfig,
cancel_event: asyncio.Event,
) -> None:
sent_count = 0
failed_count = 0
try:
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error("Запись рассылки %s не найдена в БД", broadcast_id)
return
broadcast.status = "in_progress"
broadcast.sent_count = 0
broadcast.failed_count = 0
await session.commit()
recipients = await self._fetch_recipients(config.target)
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error("Запись рассылки %s удалена до запуска", broadcast_id)
return
broadcast.total_count = len(recipients)
await session.commit()
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
if not recipients:
logger.info("Рассылка %s: получатели не найдены", broadcast_id)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
return
keyboard = self._build_keyboard(config.selected_buttons)
for index, user in enumerate(recipients, start=1):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
telegram_id = getattr(user, "telegram_id", None)
if telegram_id is None:
failed_count += 1
continue
try:
await self._deliver_message(telegram_id, config, keyboard)
sent_count += 1
except Exception as exc: # noqa: BLE001
failed_count += 1
logger.error(
"Ошибка отправки рассылки %s пользователю %s: %s",
broadcast_id,
telegram_id,
exc,
)
if index % 20 == 0:
await asyncio.sleep(1)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
except asyncio.CancelledError:
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
raise
except Exception as exc: # noqa: BLE001
logger.exception("Критическая ошибка при выполнении рассылки %s: %s", broadcast_id, exc)
await self._mark_failed(broadcast_id, sent_count, failed_count)
async def _fetch_recipients(self, target: str):
async with AsyncSessionLocal() as session:
if target.startswith("custom_"):
criteria = target[len("custom_"):]
return await get_custom_users(session, criteria)
return await get_target_users(session, target)
def _build_keyboard(self, selected_buttons: Optional[list[str]]) -> Optional[InlineKeyboardMarkup]:
if selected_buttons is None:
selected_buttons = []
return create_broadcast_keyboard(selected_buttons)
async def _deliver_message(
self,
telegram_id: int,
config: BroadcastConfig,
keyboard: Optional[InlineKeyboardMarkup],
) -> None:
if not self._bot:
raise RuntimeError("Телеграм-бот не инициализирован")
if config.media and config.media.type in VALID_MEDIA_TYPES:
caption = config.media.caption or config.message_text
if config.media.type == "photo":
await self._bot.send_photo(
chat_id=telegram_id,
photo=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == "video":
await self._bot.send_video(
chat_id=telegram_id,
video=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == "document":
await self._bot.send_document(
chat_id=telegram_id,
document=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
return
await self._bot.send_message(
chat_id=telegram_id,
text=config.message_text,
reply_markup=keyboard,
)
async def _mark_finished(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
*,
cancelled: bool,
) -> None:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count
broadcast.status = "cancelled" if cancelled else (
"completed" if failed_count == 0 else "partial"
)
broadcast.completed_at = datetime.utcnow()
await session.commit()
async def _mark_cancelled(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
) -> None:
await self._mark_finished(
broadcast_id,
sent_count,
failed_count,
cancelled=True,
)
async def _mark_failed(
self,
broadcast_id: int,
sent_count: int = 0,
failed_count: int = 0,
) -> None:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count or broadcast.failed_count
broadcast.status = "failed"
broadcast.completed_at = datetime.utcnow()
await session.commit()
broadcast_service = BroadcastService()
+156 -2
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from typing import Dict, List, Any, Optional, Set
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.enums import ChatMemberStatus
from aiogram.types import FSInputFile
from sqlalchemy import select, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
@@ -181,6 +182,7 @@ class MonitoringService:
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
await self._check_trial_inactivity_notifications(db)
await self._check_trial_channel_subscriptions(db)
await self._check_expired_subscription_followups(db)
await self._process_autopayments(db)
await self._cleanup_inactive_users(db)
@@ -279,6 +281,7 @@ class MonitoringService:
)
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
await db.commit()
status_text = "активным" if is_active else "истёкшим"
@@ -455,6 +458,145 @@ class MonitoringService:
except Exception as e:
logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}")
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
channel_id = settings.CHANNEL_SUB_ID
if not channel_id:
return
if not self.bot:
logger.debug("⚠️ Пропускаем проверку подписки на канал — бот недоступен")
return
try:
now = datetime.utcnow()
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.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
for subscription in subscriptions:
user = subscription.user
if not user or not user.telegram_id:
continue
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(
"❌ Не удалось проверить подписку пользователя %s на канал %s: бот заблокирован (%s)",
user.telegram_id,
channel_id,
error,
)
continue
except TelegramBadRequest as error:
logger.error(
"❌ Ошибка Telegram при проверке подписки пользователя %s: %s",
user.telegram_id,
error,
)
continue
except Exception as error:
logger.error(
"❌ Неожиданная ошибка при проверке подписки пользователя %s: %s",
user.telegram_id,
error,
)
continue
if subscription.status == SubscriptionStatus.ACTIVE.value and not is_member:
subscription = await deactivate_subscription(db, subscription)
disabled_count += 1
logger.info(
"🚫 Триальная подписка пользователя %s (ID %s) отключена из-за отписки от канала",
user.telegram_id,
subscription.id,
)
if user.remnawave_uuid:
try:
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
"❌ Не удалось отключить пользователя RemnaWave %s: %s",
user.remnawave_uuid,
api_error,
)
elif subscription.status == SubscriptionStatus.DISABLED.value and is_member:
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
restored_count += 1
logger.info(
"✅ Триальная подписка пользователя %s (ID %s) восстановлена после повторной подписки на канал",
user.telegram_id,
subscription.id,
)
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 пользователя %s: %s",
user.telegram_id,
api_error,
)
if disabled_count or restored_count:
await self._log_monitoring_event(
db,
"trial_channel_subscription_check",
(
"Проверено {total} триальных подписок: отключено {disabled}, "
"восстановлено {restored}"
).format(
total=len(subscriptions),
disabled=disabled_count,
restored=restored_count,
),
{
"checked": len(subscriptions),
"disabled": disabled_count,
"restored": restored_count,
},
)
except Exception as error:
logger.error(f"Ошибка проверки подписки на канал для триальных пользователей: {error}")
async def _check_expired_subscription_followups(self, db: AsyncSession):
if not NotificationSettingsService.are_notifications_globally_enabled():
return
@@ -653,7 +795,12 @@ class MonitoringService:
if success:
await extend_subscription(db, subscription, 30)
await self.subscription_service.update_remnawave_user(db, subscription)
await self.subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="автопродление подписки",
)
if self.bot:
await self._send_autopay_success_notification(user, renewal_cost, 30)
@@ -1183,10 +1330,17 @@ class MonitoringService:
if len(title) > 60:
title = title[:57] + '...'
# Детали пользователя: имя, Telegram ID и username
full_name = ticket.user.full_name if ticket.user else "Unknown"
telegram_id_display = ticket.user.telegram_id if ticket.user else ""
username_display = (ticket.user.username or "отсутствует") if ticket.user else "отсутствует"
text = (
f"⏰ <b>Ожидание ответа на тикет превышено</b>\n\n"
f"🆔 <b>ID:</b> <code>{ticket.id}</code>\n"
f"👤 <b>User ID:</b> <code>{ticket.user_id}</code>\n"
f"👤 <b>Пользователь:</b> {full_name}\n"
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
f"📱 <b>Username:</b> @{username_display}\n"
f"📝 <b>Заголовок:</b> {title or ''}\n"
f"⏱️ <b>Ожидает ответа:</b> {waited_minutes} мин\n"
)
+3
View File
@@ -37,6 +37,7 @@ class Pal24Service:
ttl_seconds: Optional[int] = None,
custom_payload: Optional[Dict[str, Any]] = None,
payer_email: Optional[str] = None,
payment_method: Optional[str] = None,
) -> Dict[str, Any]:
if not self.is_configured:
raise Pal24APIError("Pal24 service is not configured")
@@ -49,6 +50,8 @@ class Pal24Service:
if payer_email:
extra_payload["payer_email"] = payer_email
if payment_method:
extra_payload["payment_method"] = payment_method
filtered_payload = {k: v for k, v in extra_payload.items() if v not in (None, {})}
+6 -4
View File
@@ -857,6 +857,7 @@ class PaymentService:
ttl_seconds=ttl_seconds,
custom_payload=custom_payload,
payer_email=payer_email,
payment_method="SBP",
)
except Pal24APIError as error:
logger.error("Ошибка Pal24 API при создании счета: %s", error)
@@ -873,6 +874,7 @@ class PaymentService:
link_url = response.get("link_url")
link_page_url = response.get("link_page_url")
primary_link = link_page_url or link_url
payment = await create_pal24_payment(
db,
@@ -884,8 +886,8 @@ class PaymentService:
status=response.get("status", "NEW"),
type_=response.get("type", "normal"),
currency=response.get("currency", "RUB"),
link_url=link_url,
link_page_url=link_page_url,
link_url=primary_link,
link_page_url=link_page_url or link_url,
ttl=ttl_seconds,
metadata={
"raw_response": response,
@@ -896,8 +898,8 @@ class PaymentService:
payment_info = {
"bill_id": bill_id,
"order_id": order_id,
"link_url": link_url or link_page_url,
"link_page_url": link_page_url,
"link_url": primary_link,
"link_page_url": link_page_url or link_url,
"local_payment_id": payment.id,
"amount_kopeks": amount_kopeks,
}
+96 -2
View File
@@ -2,18 +2,68 @@ import logging
from datetime import datetime
from typing import Optional
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import get_user_total_spent_kopeks
from app.database.models import PromoGroup, User
from app.services.admin_notification_service import AdminNotificationService
logger = logging.getLogger(__name__)
async def _notify_admins_about_auto_assignment(
db: AsyncSession,
user: User,
old_group: Optional[PromoGroup],
new_group: PromoGroup,
total_spent_kopeks: int,
):
if not getattr(settings, "ADMIN_NOTIFICATIONS_ENABLED", False):
return
bot_token = getattr(settings, "BOT_TOKEN", None)
if not bot_token:
logger.debug("BOT_TOKEN не настроен — пропускаем уведомление о промогруппе")
return
bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode="HTML"))
try:
notification_service = AdminNotificationService(bot)
reason = (
f"Автоназначение за траты {settings.format_price(total_spent_kopeks)}"
if hasattr(settings, "format_price")
else f"Автоназначение за траты {total_spent_kopeks / 100:.2f}"
)
await notification_service.send_user_promo_group_change_notification(
db,
user,
old_group,
new_group,
reason=reason,
initiator=None,
automatic=True,
)
except Exception as exc:
logger.error(
"Ошибка отправки уведомления о автоназначении промогруппы пользователю %s: %s",
user.telegram_id,
exc,
)
finally:
try:
await bot.session.close()
except Exception:
pass
async def _get_best_group_for_spending(
db: AsyncSession,
total_spent_kopeks: int,
min_threshold_kopeks: int = 0,
) -> Optional[PromoGroup]:
if total_spent_kopeks <= 0:
return None
@@ -28,7 +78,11 @@ async def _get_best_group_for_spending(
for group in groups:
threshold = group.auto_assign_total_spent_kopeks or 0
if threshold and total_spent_kopeks >= threshold:
if (
threshold
and total_spent_kopeks >= threshold
and threshold > min_threshold_kopeks
):
return group
return None
@@ -43,16 +97,41 @@ async def maybe_assign_promo_group_by_total_spent(
logger.debug("Не удалось найти пользователя %s для автовыдачи промогруппы", user_id)
return None
old_group = None
if user.promo_group_id:
try:
await db.refresh(user, attribute_names=["promo_group"])
except Exception:
pass
old_group = getattr(user, "promo_group", None)
total_spent = await get_user_total_spent_kopeks(db, user_id)
if total_spent <= 0:
return None
target_group = await _get_best_group_for_spending(db, total_spent)
previous_threshold = user.auto_promo_group_threshold_kopeks or 0
target_group = await _get_best_group_for_spending(
db,
total_spent,
min_threshold_kopeks=previous_threshold,
)
if not target_group:
return None
try:
previous_group_id = user.promo_group_id
target_threshold = target_group.auto_assign_total_spent_kopeks or 0
if target_threshold <= previous_threshold:
logger.debug(
"Порог промогруппы '%s' (%s) не превышает ранее назначенный (%s) для пользователя %s",
target_group.name,
target_threshold,
previous_threshold,
user.telegram_id,
)
return None
if user.auto_promo_group_assigned and target_group.id == previous_group_id:
logger.debug(
@@ -60,9 +139,15 @@ async def maybe_assign_promo_group_by_total_spent(
user.telegram_id,
target_group.name,
)
if target_threshold > previous_threshold:
user.auto_promo_group_threshold_kopeks = target_threshold
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
return target_group
user.auto_promo_group_assigned = True
user.auto_promo_group_threshold_kopeks = target_threshold
user.updated_at = datetime.utcnow()
if target_group.id != previous_group_id:
@@ -84,6 +169,15 @@ async def maybe_assign_promo_group_by_total_spent(
await db.commit()
await db.refresh(user)
if target_group.id != previous_group_id:
await _notify_admins_about_auto_assignment(
db,
user,
old_group,
target_group,
total_spent,
)
return target_group
except Exception as exc:
logger.error(
+18 -7
View File
@@ -49,23 +49,34 @@ class PromoCodeService:
return {"success": False, "error": "already_used_by_user"}
result_description = await self._apply_promocode_effects(db, user, promocode)
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
from app.utils.user_utils import mark_user_as_had_paid_subscription
await mark_user_as_had_paid_subscription(db, user)
logger.info(f"🎯 Пользователь {user.telegram_id} получил платную подписку через промокод {code}")
await create_promocode_use(db, promocode.id, user_id)
promocode.current_uses += 1
await db.commit()
logger.info(f"✅ Пользователь {user.telegram_id} активировал промокод {code}")
promocode_data = {
"code": promocode.code,
"type": promocode.type,
"balance_bonus_kopeks": promocode.balance_bonus_kopeks,
"subscription_days": promocode.subscription_days,
"max_uses": promocode.max_uses,
"current_uses": promocode.current_uses,
"valid_until": promocode.valid_until,
}
return {
"success": True,
"description": result_description
"description": result_description,
"promocode": promocode_data,
}
except Exception as e:
+114 -43
View File
@@ -1,4 +1,5 @@
import logging
from contextlib import asynccontextmanager
from typing import Dict, List, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete
@@ -20,17 +21,56 @@ from app.database.models import (
logger = logging.getLogger(__name__)
class RemnaWaveConfigurationError(Exception):
"""Raised when RemnaWave API configuration is missing."""
class RemnaWaveService:
def __init__(self):
auth_params = settings.get_remnawave_auth_params()
self.api = RemnaWaveAPI(
base_url=auth_params["base_url"],
api_key=auth_params["api_key"],
secret_key=auth_params["secret_key"],
username=auth_params["username"],
password=auth_params["password"]
)
base_url = (auth_params.get("base_url") or "").strip()
api_key = (auth_params.get("api_key") or "").strip()
self._config_error: Optional[str] = None
if not base_url:
self._config_error = "REMNAWAVE_API_URL не настроен"
elif not api_key:
self._config_error = "REMNAWAVE_API_KEY не настроен"
self.api: Optional[RemnaWaveAPI]
if self._config_error:
self.api = None
else:
self.api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=auth_params.get("secret_key"),
username=auth_params.get("username"),
password=auth_params.get("password")
)
@property
def is_configured(self) -> bool:
return self._config_error is None
@property
def configuration_error(self) -> Optional[str]:
return self._config_error
def _ensure_configured(self) -> None:
if not self.is_configured or self.api is None:
raise RemnaWaveConfigurationError(
self._config_error or "RemnaWave API не настроен"
)
@asynccontextmanager
async def get_api_client(self):
self._ensure_configured()
assert self.api is not None
async with self.api as api:
yield api
def _parse_remnawave_date(self, date_str: str) -> datetime:
if not date_str:
@@ -62,7 +102,7 @@ class RemnaWaveService:
async def get_system_statistics(self) -> Dict[str, Any]:
try:
async with self.api as api:
async with self.get_api_client() as api:
logger.info("Получение системной статистики RemnaWave...")
try:
@@ -255,7 +295,7 @@ class RemnaWaveService:
async def get_all_nodes(self) -> List[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
nodes = await api.get_all_nodes()
result = []
@@ -284,7 +324,7 @@ class RemnaWaveService:
async def test_connection(self) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
stats = await api.get_system_stats()
logger.info("✅ Соединение с Remnawave API работает")
return True
@@ -295,7 +335,7 @@ class RemnaWaveService:
async def get_node_details(self, node_uuid: str) -> Optional[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
node = await api.get_node_by_uuid(node_uuid)
if not node:
@@ -321,7 +361,7 @@ class RemnaWaveService:
async def manage_node(self, node_uuid: str, action: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
if action == "enable":
await api.enable_node(node_uuid)
elif action == "disable":
@@ -340,7 +380,7 @@ class RemnaWaveService:
async def restart_all_nodes(self) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
result = await api.restart_all_nodes()
if result:
@@ -354,7 +394,7 @@ class RemnaWaveService:
async def update_squad_inbounds(self, squad_uuid: str, inbound_uuids: List[str]) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
data = {
'uuid': squad_uuid,
'inbounds': inbound_uuids
@@ -368,7 +408,7 @@ class RemnaWaveService:
async def get_all_squads(self) -> List[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
squads = await api.get_internal_squads()
result = []
@@ -390,7 +430,7 @@ class RemnaWaveService:
async def create_squad(self, name: str, inbounds: List[str]) -> Optional[str]:
try:
async with self.api as api:
async with self.get_api_client() as api:
squad = await api.create_internal_squad(name, inbounds)
logger.info(f"✅ Создан новый сквад: {name}")
@@ -402,7 +442,7 @@ class RemnaWaveService:
async def update_squad(self, uuid: str, name: str = None, inbounds: List[str] = None) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
await api.update_internal_squad(uuid, name, inbounds)
logger.info(f"✅ Обновлен сквад {uuid}")
@@ -414,7 +454,7 @@ class RemnaWaveService:
async def delete_squad(self, uuid: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
result = await api.delete_internal_squad(uuid)
if result:
@@ -432,7 +472,7 @@ class RemnaWaveService:
logger.info(f"🔄 Начинаем синхронизацию типа: {sync_type}")
async with self.api as api:
async with self.get_api_client() as api:
panel_users = []
start = 0
size = 100
@@ -458,6 +498,7 @@ class RemnaWaveService:
'usedTrafficBytes': user_obj.used_traffic_bytes,
'hwidDeviceLimit': user_obj.hwid_device_limit,
'subscriptionUrl': user_obj.subscription_url,
'subscriptionCryptoLink': user_obj.happ_crypto_link,
'activeInternalSquads': user_obj.active_internal_squads
}
panel_users.append(user_dict)
@@ -549,7 +590,7 @@ class RemnaWaveService:
if db_user.remnawave_uuid:
try:
async with self.api as api:
async with self.get_api_client() as api:
devices_reset = await api.reset_user_devices(db_user.remnawave_uuid)
if devices_reset:
logger.info(f"🔧 Сброшены HWID устройства для пользователя {telegram_id}")
@@ -581,6 +622,7 @@ class RemnaWaveService:
subscription.autopay_enabled = False
subscription.remnawave_short_uuid = None
subscription.subscription_url = ""
subscription.subscription_crypto_link = ""
db_user.remnawave_uuid = None
@@ -637,14 +679,18 @@ class RemnaWaveService:
subscription_data = {
'user_id': user.id,
'status': status.value,
'is_trial': False,
'is_trial': False,
'end_date': expire_at,
'traffic_limit_gb': traffic_limit_gb,
'traffic_used_gb': traffic_used_gb,
'device_limit': panel_user.get('hwidDeviceLimit', 1) or 1,
'connected_squads': squad_uuids,
'remnawave_short_uuid': panel_user.get('shortUuid'),
'subscription_url': panel_user.get('subscriptionUrl', '')
'subscription_url': panel_user.get('subscriptionUrl', ''),
'subscription_crypto_link': (
panel_user.get('subscriptionCryptoLink')
or (panel_user.get('happ') or {}).get('cryptoLink', '')
)
}
subscription = await create_subscription(db, **subscription_data)
@@ -667,7 +713,11 @@ class RemnaWaveService:
device_limit=1,
connected_squads=[],
remnawave_short_uuid=panel_user.get('shortUuid'),
subscription_url=panel_user.get('subscriptionUrl', '')
subscription_url=panel_user.get('subscriptionUrl', ''),
subscription_crypto_link=(
panel_user.get('subscriptionCryptoLink')
or (panel_user.get('happ') or {}).get('cryptoLink', '')
)
)
logger.info(f"✅ Создана базовая подписка для пользователя {user.telegram_id}")
except Exception as basic_error:
@@ -733,6 +783,13 @@ class RemnaWaveService:
panel_url = panel_user.get('subscriptionUrl', '')
if not subscription.subscription_url or subscription.subscription_url != panel_url:
subscription.subscription_url = panel_url
panel_crypto_link = (
panel_user.get('subscriptionCryptoLink')
or (panel_user.get('happ') or {}).get('cryptoLink', '')
)
if panel_crypto_link and subscription.subscription_crypto_link != panel_crypto_link:
subscription.subscription_crypto_link = panel_crypto_link
active_squads = panel_user.get('activeInternalSquads', [])
squad_uuids = []
@@ -763,7 +820,7 @@ class RemnaWaveService:
users = await get_users_list(db, offset=0, limit=10000)
async with self.api as api:
async with self.get_api_client() as api:
for user in users:
if not user.subscription:
continue
@@ -825,7 +882,7 @@ class RemnaWaveService:
async def get_user_traffic_stats(self, telegram_id: int) -> Optional[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
users = await api.get_user_by_telegram_id(telegram_id)
if not users:
@@ -848,17 +905,23 @@ class RemnaWaveService:
return None
async def test_api_connection(self) -> Dict[str, Any]:
if not self.is_configured:
return {
"status": "not_configured",
"message": self.configuration_error or "RemnaWave API не настроен",
"api_url": settings.REMNAWAVE_API_URL,
}
try:
async with self.api as api:
async with self.get_api_client() as api:
system_stats = await api.get_system_stats()
return {
"status": "connected",
"message": "Подключение успешно",
"api_url": settings.REMNAWAVE_API_URL,
"system_info": system_stats
}
except RemnaWaveAPIError as e:
return {
"status": "error",
@@ -866,6 +929,12 @@ class RemnaWaveService:
"status_code": e.status_code,
"api_url": settings.REMNAWAVE_API_URL
}
except RemnaWaveConfigurationError as e:
return {
"status": "not_configured",
"message": str(e),
"api_url": settings.REMNAWAVE_API_URL,
}
except Exception as e:
return {
"status": "error",
@@ -875,7 +944,7 @@ class RemnaWaveService:
async def get_nodes_realtime_usage(self) -> List[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
usage_data = await api.get_nodes_realtime_usage()
return usage_data
@@ -885,7 +954,7 @@ class RemnaWaveService:
async def get_squad_details(self, squad_uuid: str) -> Optional[Dict]:
try:
async with self.api as api:
async with self.get_api_client() as api:
squad = await api.get_internal_squad_by_uuid(squad_uuid)
if squad:
return {
@@ -902,7 +971,7 @@ class RemnaWaveService:
async def add_all_users_to_squad(self, squad_uuid: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
response = await api._make_request('POST', f'/api/internal-squads/{squad_uuid}/bulk-actions/add-users')
return response.get('response', {}).get('eventSent', False)
except Exception as e:
@@ -911,7 +980,7 @@ class RemnaWaveService:
async def remove_all_users_from_squad(self, squad_uuid: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
response = await api._make_request('DELETE', f'/api/internal-squads/{squad_uuid}/bulk-actions/remove-users')
return response.get('response', {}).get('eventSent', False)
except Exception as e:
@@ -920,7 +989,7 @@ class RemnaWaveService:
async def delete_squad(self, squad_uuid: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
response = await api.delete_internal_squad(squad_uuid)
return response
except Exception as e:
@@ -929,7 +998,7 @@ class RemnaWaveService:
async def get_all_inbounds(self) -> List[Dict]:
try:
async with self.api as api:
async with self.get_api_client() as api:
response = await api._make_request('GET', '/api/config-profiles/inbounds')
inbounds_data = response.get('response', {}).get('inbounds', [])
@@ -950,7 +1019,7 @@ class RemnaWaveService:
async def rename_squad(self, squad_uuid: str, new_name: str) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
data = {
'uuid': squad_uuid,
'name': new_name
@@ -963,7 +1032,7 @@ class RemnaWaveService:
async def create_squad(self, name: str, inbound_uuids: List[str]) -> bool:
try:
async with self.api as api:
async with self.get_api_client() as api:
squad = await api.create_internal_squad(name, inbound_uuids)
return squad is not None
except Exception as e:
@@ -972,7 +1041,7 @@ class RemnaWaveService:
async def get_node_user_usage_by_range(self, node_uuid: str, start_date, end_date) -> List[Dict[str, Any]]:
try:
async with self.api as api:
async with self.get_api_client() as api:
start_str = start_date.isoformat() + "Z"
end_str = end_date.isoformat() + "Z"
@@ -1050,7 +1119,7 @@ class RemnaWaveService:
if user.remnawave_uuid:
try:
async with self.api as api:
async with self.get_api_client() as api:
devices_reset = await api.reset_user_devices(user.remnawave_uuid)
if devices_reset:
logger.info(f"🔧 Сброшены HWID устройства для {user.telegram_id}")
@@ -1113,6 +1182,7 @@ class RemnaWaveService:
user.subscription.autopay_days_before = 3
user.subscription.remnawave_short_uuid = None
user.subscription.subscription_url = ""
user.subscription.subscription_crypto_link = ""
user.subscription.updated_at = datetime.utcnow()
await db.commit()
@@ -1136,7 +1206,7 @@ class RemnaWaveService:
logger.info("🧹 Начинаем усиленную очистку неактуальных подписок...")
async with self.api as api:
async with self.get_api_client() as api:
panel_users_data = await api._make_request('GET', '/api/users')
panel_users = panel_users_data['response']['users']
@@ -1200,7 +1270,7 @@ class RemnaWaveService:
logger.info("🔄 Начинаем синхронизацию статусов подписок...")
async with self.api as api:
async with self.get_api_client() as api:
panel_users_data = await api._make_request('GET', '/api/users')
panel_users = panel_users_data['response']['users']
@@ -1290,11 +1360,12 @@ class RemnaWaveService:
if not subscription.remnawave_short_uuid and user.remnawave_uuid:
try:
async with self.api as api:
async with self.get_api_client() as api:
rw_user = await api.get_user_by_uuid(user.remnawave_uuid)
if rw_user:
subscription.remnawave_short_uuid = rw_user.short_uuid
subscription.subscription_url = rw_user.subscription_url
subscription.subscription_crypto_link = rw_user.happ_crypto_link
logger.info(f"🔧 Восстановлены данные Remnawave для {user.telegram_id}")
issues_fixed += 1
except Exception as rw_error:
@@ -1538,7 +1609,7 @@ class RemnaWaveService:
try:
start_time = datetime.utcnow()
async with self.api as api:
async with self.get_api_client() as api:
try:
system_stats = await api.get_system_stats()
api_available = True
+27
View File
@@ -17,6 +17,8 @@ from app.database.database import AsyncSessionLocal
from app.database.models import (
Subscription,
SubscriptionConversion,
Ticket,
TicketStatus,
Transaction,
TransactionType,
)
@@ -203,6 +205,10 @@ class ReportingService:
f"• Активных сейчас: {totals['active_paid']}",
f"• Новых за период: {period_stats['new_paid_subscriptions']}",
"",
"🎟️ <b>Тикеты поддержки</b>",
f"• Активных сейчас: {totals['open_tickets']}",
f"• Новых за период: {period_stats['new_tickets']}",
"",
"💰 <b>Платежи</b>",
f"• Оплат подписок: {period_stats['subscription_payments_count']} на сумму "
f"{self._format_amount(period_stats['subscription_payments_amount'])}",
@@ -243,9 +249,22 @@ class ReportingService:
async def _collect_current_totals(self, session) -> dict:
stats = await get_subscriptions_statistics(session)
open_tickets_result = await session.execute(
select(func.count(Ticket.id)).where(
Ticket.status.in_(
[
TicketStatus.OPEN.value,
TicketStatus.ANSWERED.value,
TicketStatus.PENDING.value,
]
)
)
)
open_tickets = int(open_tickets_result.scalar() or 0)
return {
"active_trials": stats.get("trial_subscriptions", 0) or 0,
"active_paid": stats.get("paid_subscriptions", 0) or 0,
"open_tickets": open_tickets,
}
async def _collect_period_stats(
@@ -315,6 +334,13 @@ class ReportingService:
total_payments_count = subscription_payments_count + deposits_count
total_payments_amount = subscription_payments_amount + deposits_amount
new_tickets_result = await session.execute(
select(func.count(Ticket.id)).where(
Ticket.created_at >= start_utc,
Ticket.created_at < end_utc,
)
)
new_tickets = int(new_tickets_result.scalar() or 0)
return {
"new_trials": new_trials,
@@ -325,6 +351,7 @@ class ReportingService:
"deposits_amount": deposits_amount,
"total_payments_count": total_payments_count,
"total_payments_amount": total_payments_amount,
"new_tickets": new_tickets,
}
def _format_period_label(self, start: datetime, end: datetime) -> str:
+87 -12
View File
@@ -38,6 +38,26 @@ def _resolve_discount_percent(
return 0
def _resolve_addon_discount_percent(
user: Optional[User],
promo_group: Optional[PromoGroup],
category: str,
*,
period_days: Optional[int] = None,
) -> int:
group = promo_group or (getattr(user, "promo_group", None) if user else None)
if group is not None and not getattr(group, "apply_discounts_to_addons", True):
return 0
return _resolve_discount_percent(
user,
promo_group,
category,
period_days=period_days,
)
def get_traffic_reset_strategy():
from app.config import settings
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
@@ -67,9 +87,12 @@ class SubscriptionService:
)
async def create_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
self,
db: AsyncSession,
subscription: Subscription,
*,
reset_traffic: bool = False,
reset_reason: Optional[str] = None,
) -> Optional[RemnaWaveUser]:
try:
@@ -110,6 +133,14 @@ class SubscriptionService:
active_internal_squads=subscription.connected_squads
)
if reset_traffic:
await self._reset_user_traffic(
api,
updated_user.uuid,
user.telegram_id,
reset_reason,
)
else:
logger.info(f"🆕 Создаем нового пользователя в панели для {user.telegram_id}")
username = f"user_{user.telegram_id}"
@@ -128,9 +159,18 @@ class SubscriptionService:
),
active_internal_squads=subscription.connected_squads
)
if reset_traffic:
await self._reset_user_traffic(
api,
updated_user.uuid,
user.telegram_id,
reset_reason,
)
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
user.remnawave_uuid = updated_user.uuid
await db.commit()
@@ -151,7 +191,10 @@ class SubscriptionService:
async def update_remnawave_user(
self,
db: AsyncSession,
subscription: Subscription
subscription: Subscription,
*,
reset_traffic: bool = False,
reset_reason: Optional[str] = None,
) -> Optional[RemnaWaveUser]:
try:
@@ -189,7 +232,16 @@ class SubscriptionService:
active_internal_squads=subscription.connected_squads
)
if reset_traffic:
await self._reset_user_traffic(
api,
user.remnawave_uuid,
user.telegram_id,
reset_reason,
)
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
await db.commit()
status_text = "активным" if is_actually_active else "истёкшим"
@@ -197,16 +249,37 @@ class SubscriptionService:
strategy_name = settings.DEFAULT_TRAFFIC_RESET_STRATEGY
logger.info(f"📊 Стратегия сброса трафика: {strategy_name}")
return updated_user
except RemnaWaveAPIError as e:
logger.error(f"Ошибка RemnaWave API: {e}")
return None
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave пользователя: {e}")
return None
async def _reset_user_traffic(
self,
api: RemnaWaveAPI,
user_uuid: str,
telegram_id: int,
reset_reason: Optional[str] = None,
) -> None:
if not user_uuid:
return
try:
await api.reset_user_traffic(user_uuid)
reason_text = f" ({reset_reason})" if reset_reason else ""
logger.info(
f"🔄 Сброшен трафик RemnaWave для пользователя {telegram_id}{reason_text}"
)
except Exception as exc:
logger.warning(
f"⚠️ Не удалось сбросить трафик RemnaWave для пользователя {telegram_id}: {exc}"
)
async def disable_remnawave_user(self, user_uuid: str) -> bool:
try:
async with self.api as api:
await api.disable_user(user_uuid)
@@ -233,6 +306,7 @@ class SubscriptionService:
subscription.remnawave_short_uuid = updated_user.short_uuid
subscription.subscription_url = updated_user.subscription_url
subscription.subscription_crypto_link = updated_user.happ_crypto_link
await db.commit()
logger.info(f"✅ Обновлена ссылка подписки для пользователя {user.telegram_id}")
@@ -534,6 +608,7 @@ class SubscriptionService:
subscription.remnawave_short_uuid = None
subscription.subscription_url = ""
subscription.subscription_crypto_link = ""
subscription.connected_squads = []
user.remnawave_uuid = None
@@ -854,7 +929,7 @@ class SubscriptionService:
if additional_traffic_gb > 0:
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
traffic_discount_percent = _resolve_discount_percent(
traffic_discount_percent = _resolve_addon_discount_percent(
user,
promo_group,
"traffic",
@@ -877,7 +952,7 @@ class SubscriptionService:
if additional_devices > 0:
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
devices_discount_percent = _resolve_addon_discount_percent(
user,
promo_group,
"devices",
@@ -904,7 +979,7 @@ class SubscriptionService:
server = await get_server_squad_by_id(db, server_id)
if server and server.is_available:
server_price_per_month = server.price_kopeks
servers_discount_percent = _resolve_discount_percent(
servers_discount_percent = _resolve_addon_discount_percent(
user,
promo_group,
"servers",
+704
View File
@@ -0,0 +1,704 @@
import hashlib
import json
import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple, Type, Union, get_args, get_origin
from app.database.universal_migration import ensure_default_web_api_token
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import Settings, settings
from app.database.crud.system_setting import (
delete_system_setting,
upsert_system_setting,
)
from app.database.database import AsyncSessionLocal
from app.database.models import SystemSetting
logger = logging.getLogger(__name__)
def _title_from_key(key: str) -> str:
parts = key.split("_")
if not parts:
return key
return " ".join(part.capitalize() for part in parts)
def _truncate(value: str, max_len: int = 60) -> str:
value = value.strip()
if len(value) <= max_len:
return value
return value[: max_len - 1] + ""
@dataclass(slots=True)
class SettingDefinition:
key: str
category_key: str
category_label: str
python_type: Type[Any]
type_label: str
is_optional: bool
@property
def display_name(self) -> str:
return _title_from_key(self.key)
@dataclass(slots=True)
class ChoiceOption:
value: Any
label: str
description: Optional[str] = None
class BotConfigurationService:
EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"}
CATEGORY_TITLES: Dict[str, str] = {
"SUPPORT": "💬 Ссылка на поддержку",
"LOCALIZATION": "🌍 Языковые настройки",
"MAINTENANCE": "🛠️ Режим обслуживания",
"CHANNEL": "📣 Обязательная подписка",
"ADMIN_NOTIFICATIONS": "🔔 Уведомления админам",
"ADMIN_REPORTS": "📊 Автоматические отчеты",
"TRIAL": "🎁 Триальная подписка",
"PAID_SUBSCRIPTION": "💰 Платные подписки",
"PERIODS": "📅 Периоды подписки",
"SUBSCRIPTION_PRICES": "💵 Цены за периоды",
"TRAFFIC": "🚦 Настройки трафика",
"TRAFFIC_PACKAGES": "📦 Пакеты трафика",
"DISCOUNTS": "🎯 Промо и скидки",
"PAYMENT": "⚙️ Общие настройки платежей",
"TELEGRAM": "⭐ Telegram Stars",
"CRYPTOBOT": "💎 CryptoBot",
"YOOKASSA": "💸 YooKassa",
"TRIBUTE": "🎁 Tribute",
"MULENPAY": "💰 MulenPay",
"PAL24": "🏦 Pal24/PayPalych",
"REMNAWAVE": "🔗 RemnaWave API",
"REFERRAL": "🤝 Реферальная система",
"AUTOPAY": "🔄 Автопродление",
"INTERFACE_BRANDING": "🖼️ Визуальные настройки",
"INTERFACE_SUBSCRIPTION": "🔗 Скрыть ссылку подписки",
"CONNECT_BUTTON": "🚀 Кнопка «Подключиться»",
"HAPP": "🅷 Happ настройки",
"SKIP": "⚡ Быстрый старт",
"ADDITIONAL": "📱 Приложения и DeepLinks",
"DATABASE": "🗄️ Режим БД",
"POSTGRES": "🐘 PostgreSQL",
"SQLITE": "💾 SQLite",
"REDIS": "🧠 Redis",
"MONITORING": "📈 Общий мониторинг",
"NOTIFICATIONS": "🔔 Уведомления пользователям",
"SERVER": "🖥️ Статус серверов",
"BACKUP": "💾 Система бэкапов",
"VERSION": "🔄 Обновления",
"LOG": "📝 Логирование",
"WEBHOOK": "🌐 Webhook",
"WEB_API": "🌐 Web API",
"DEBUG": "🔧 Режим разработки",
}
CATEGORY_KEY_OVERRIDES: Dict[str, str] = {
"DATABASE_URL": "DATABASE",
"DATABASE_MODE": "DATABASE",
"LOCALES_PATH": "DATABASE",
"DEFAULT_DEVICE_LIMIT": "PAID_SUBSCRIPTION",
"DEFAULT_TRAFFIC_LIMIT_GB": "PAID_SUBSCRIPTION",
"MAX_DEVICES_LIMIT": "PAID_SUBSCRIPTION",
"PRICE_PER_DEVICE": "PAID_SUBSCRIPTION",
"DEFAULT_TRAFFIC_RESET_STRATEGY": "TRAFFIC",
"RESET_TRAFFIC_ON_PAYMENT": "TRAFFIC",
"TRAFFIC_SELECTION_MODE": "TRAFFIC",
"FIXED_TRAFFIC_LIMIT_GB": "TRAFFIC",
"AVAILABLE_SUBSCRIPTION_PERIODS": "PERIODS",
"AVAILABLE_RENEWAL_PERIODS": "PERIODS",
"BASE_SUBSCRIPTION_PRICE": "SUBSCRIPTION_PRICES",
"PRICE_14_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_30_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_60_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_90_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_180_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_360_DAYS": "SUBSCRIPTION_PRICES",
"PRICE_TRAFFIC_5GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_10GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_25GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_50GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_100GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_250GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_500GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_1000GB": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC_UNLIMITED": "TRAFFIC_PACKAGES",
"TRAFFIC_PACKAGES_CONFIG": "TRAFFIC_PACKAGES",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED": "DISCOUNTS",
"BASE_PROMO_GROUP_PERIOD_DISCOUNTS": "DISCOUNTS",
"REFERRED_USER_REWARD": "REFERRAL",
"DEFAULT_AUTOPAY_DAYS_BEFORE": "AUTOPAY",
"MIN_BALANCE_FOR_AUTOPAY_KOPEKS": "AUTOPAY",
"TRIAL_WARNING_HOURS": "NOTIFICATIONS",
"ENABLE_NOTIFICATIONS": "NOTIFICATIONS",
"NOTIFICATION_RETRY_ATTEMPTS": "NOTIFICATIONS",
"NOTIFICATION_CACHE_HOURS": "NOTIFICATIONS",
"MONITORING_LOGS_RETENTION_DAYS": "MONITORING",
"ENABLE_LOGO_MODE": "INTERFACE_BRANDING",
"LOGO_FILE": "INTERFACE_BRANDING",
"HIDE_SUBSCRIPTION_LINK": "INTERFACE_SUBSCRIPTION",
"CONNECT_BUTTON_MODE": "CONNECT_BUTTON",
"MINIAPP_CUSTOM_URL": "CONNECT_BUTTON",
"APP_CONFIG_PATH": "ADDITIONAL",
"ENABLE_DEEP_LINKS": "ADDITIONAL",
"APP_CONFIG_CACHE_TTL": "ADDITIONAL",
"DEFAULT_LANGUAGE": "LOCALIZATION",
"AVAILABLE_LANGUAGES": "LOCALIZATION",
"PAYMENT_SERVICE_NAME": "PAYMENT",
"PAYMENT_BALANCE_DESCRIPTION": "PAYMENT",
"PAYMENT_SUBSCRIPTION_DESCRIPTION": "PAYMENT",
"PAYMENT_BALANCE_TEMPLATE": "PAYMENT",
"PAYMENT_SUBSCRIPTION_TEMPLATE": "PAYMENT",
"INACTIVE_USER_DELETE_MONTHS": "MONITORING",
"LANGUAGE_SELECTION_ENABLED": "LOCALIZATION",
}
CATEGORY_PREFIX_OVERRIDES: Dict[str, str] = {
"SUPPORT_": "SUPPORT",
"ADMIN_NOTIFICATIONS": "ADMIN_NOTIFICATIONS",
"ADMIN_REPORTS": "ADMIN_REPORTS",
"CHANNEL_": "CHANNEL",
"POSTGRES_": "POSTGRES",
"SQLITE_": "SQLITE",
"REDIS_": "REDIS",
"REMNAWAVE": "REMNAWAVE",
"TRIAL_": "TRIAL",
"TRAFFIC_PACKAGES": "TRAFFIC_PACKAGES",
"PRICE_TRAFFIC": "TRAFFIC_PACKAGES",
"TRAFFIC_": "TRAFFIC",
"REFERRAL_": "REFERRAL",
"AUTOPAY_": "AUTOPAY",
"TELEGRAM_STARS": "TELEGRAM",
"TRIBUTE_": "TRIBUTE",
"YOOKASSA_": "YOOKASSA",
"CRYPTOBOT_": "CRYPTOBOT",
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
"PAYMENT_": "PAYMENT",
"CONNECT_BUTTON_HAPP": "HAPP",
"HAPP_": "HAPP",
"SKIP_": "SKIP",
"MONITORING_": "MONITORING",
"NOTIFICATION_": "NOTIFICATIONS",
"SERVER_STATUS": "SERVER",
"MAINTENANCE_": "MAINTENANCE",
"VERSION_CHECK": "VERSION",
"BACKUP_": "BACKUP",
"WEBHOOK_": "WEBHOOK",
"LOG_": "LOG",
"WEB_API_": "WEB_API",
"DEBUG": "DEBUG",
}
CHOICES: Dict[str, List[ChoiceOption]] = {
"DATABASE_MODE": [
ChoiceOption("auto", "🤖 Авто"),
ChoiceOption("postgresql", "🐘 PostgreSQL"),
ChoiceOption("sqlite", "💾 SQLite"),
],
"REMNAWAVE_AUTH_TYPE": [
ChoiceOption("api_key", "🔑 API Key"),
ChoiceOption("basic_auth", "🧾 Basic Auth"),
],
"REMNAWAVE_USER_DELETE_MODE": [
ChoiceOption("delete", "🗑 Удалять"),
ChoiceOption("disable", "🚫 Деактивировать"),
],
"TRAFFIC_SELECTION_MODE": [
ChoiceOption("selectable", "📦 Выбор пакетов"),
ChoiceOption("fixed", "📏 Фиксированный лимит"),
],
"DEFAULT_TRAFFIC_RESET_STRATEGY": [
ChoiceOption("NO_RESET", "♾️ Без сброса"),
ChoiceOption("DAY", "📅 Ежедневно"),
ChoiceOption("WEEK", "🗓 Еженедельно"),
ChoiceOption("MONTH", "📆 Ежемесячно"),
],
"SUPPORT_SYSTEM_MODE": [
ChoiceOption("tickets", "🎫 Только тикеты"),
ChoiceOption("contact", "💬 Только контакт"),
ChoiceOption("both", "🔁 Оба варианта"),
],
"CONNECT_BUTTON_MODE": [
ChoiceOption("guide", "📘 Гайд"),
ChoiceOption("miniapp_subscription", "🧾 Mini App подписка"),
ChoiceOption("miniapp_custom", "🧩 Mini App (ссылка)"),
ChoiceOption("link", "🔗 Прямая ссылка"),
ChoiceOption("happ_cryptolink", "🪙 Happ CryptoLink"),
],
"SERVER_STATUS_MODE": [
ChoiceOption("disabled", "🚫 Отключено"),
ChoiceOption("external_link", "🌐 Внешняя ссылка"),
ChoiceOption("external_link_miniapp", "🧭 Mini App ссылка"),
ChoiceOption("xray", "📊 XRay Checker"),
],
"YOOKASSA_PAYMENT_MODE": [
ChoiceOption("full_payment", "💳 Полная оплата"),
ChoiceOption("partial_payment", "🪙 Частичная оплата"),
ChoiceOption("advance", "💼 Аванс"),
ChoiceOption("full_prepayment", "📦 Полная предоплата"),
ChoiceOption("partial_prepayment", "📦 Частичная предоплата"),
ChoiceOption("credit", "💰 Кредит"),
ChoiceOption("credit_payment", "💸 Погашение кредита"),
],
"YOOKASSA_PAYMENT_SUBJECT": [
ChoiceOption("commodity", "📦 Товар"),
ChoiceOption("excise", "🥃 Подакцизный товар"),
ChoiceOption("job", "🛠 Работа"),
ChoiceOption("service", "🧾 Услуга"),
ChoiceOption("gambling_bet", "🎲 Ставка"),
ChoiceOption("gambling_prize", "🏆 Выигрыш"),
ChoiceOption("lottery", "🎫 Лотерея"),
ChoiceOption("lottery_prize", "🎁 Приз лотереи"),
ChoiceOption("intellectual_activity", "🧠 Интеллектуальная деятельность"),
ChoiceOption("payment", "💱 Платеж"),
ChoiceOption("agent_commission", "🤝 Комиссия агента"),
ChoiceOption("composite", "🧩 Композитный"),
ChoiceOption("another", "📄 Другое"),
],
"YOOKASSA_VAT_CODE": [
ChoiceOption(1, "1 — НДС не облагается"),
ChoiceOption(2, "2 — НДС 0%"),
ChoiceOption(3, "3 — НДС 10%"),
ChoiceOption(4, "4 — НДС 20%"),
ChoiceOption(5, "5 — НДС 10/110"),
ChoiceOption(6, "6 — НДС 20/120"),
],
"MULENPAY_LANGUAGE": [
ChoiceOption("ru", "🇷🇺 Русский"),
ChoiceOption("en", "🇬🇧 Английский"),
],
"LOG_LEVEL": [
ChoiceOption("DEBUG", "🐞 Debug"),
ChoiceOption("INFO", "️ Info"),
ChoiceOption("WARNING", "⚠️ Warning"),
ChoiceOption("ERROR", "❌ Error"),
ChoiceOption("CRITICAL", "🔥 Critical"),
],
}
_definitions: Dict[str, SettingDefinition] = {}
_original_values: Dict[str, Any] = settings.model_dump()
_overrides_raw: Dict[str, Optional[str]] = {}
_callback_tokens: Dict[str, str] = {}
_token_to_key: Dict[str, str] = {}
_choice_tokens: Dict[str, Dict[Any, str]] = {}
_choice_token_lookup: Dict[str, Dict[str, Any]] = {}
@classmethod
def initialize_definitions(cls) -> None:
if cls._definitions:
return
for key, field in Settings.model_fields.items():
if key in cls.EXCLUDED_KEYS:
continue
annotation = field.annotation
python_type, is_optional = cls._normalize_type(annotation)
type_label = cls._type_to_label(python_type, is_optional)
category_key = cls._resolve_category_key(key)
category_label = cls.CATEGORY_TITLES.get(
category_key,
category_key.capitalize() if category_key else "Прочее",
)
cls._definitions[key] = SettingDefinition(
key=key,
category_key=category_key or "other",
category_label=category_label,
python_type=python_type,
type_label=type_label,
is_optional=is_optional,
)
cls._register_callback_token(key)
if key in cls.CHOICES:
cls._ensure_choice_tokens(key)
@classmethod
def _resolve_category_key(cls, key: str) -> str:
override = cls.CATEGORY_KEY_OVERRIDES.get(key)
if override:
return override
for prefix, category in sorted(
cls.CATEGORY_PREFIX_OVERRIDES.items(), key=lambda item: len(item[0]), reverse=True
):
if key.startswith(prefix):
return category
if "_" not in key:
return key.upper()
prefix = key.split("_", 1)[0]
return prefix.upper()
@classmethod
def _normalize_type(cls, annotation: Any) -> Tuple[Type[Any], bool]:
if annotation is None:
return str, True
origin = get_origin(annotation)
if origin is Union:
args = [arg for arg in get_args(annotation) if arg is not type(None)]
if len(args) == 1:
nested_type, nested_optional = cls._normalize_type(args[0])
return nested_type, True
return str, True
if annotation in {int, float, bool, str}:
return annotation, False
if annotation in {Optional[int], Optional[float], Optional[bool], Optional[str]}:
nested = get_args(annotation)[0]
return nested, True
# Paths, lists, dicts и прочее будем хранить как строки
return str, False
@classmethod
def _type_to_label(cls, python_type: Type[Any], is_optional: bool) -> str:
base = {
bool: "bool",
int: "int",
float: "float",
str: "str",
}.get(python_type, "str")
return f"optional[{base}]" if is_optional else base
@classmethod
def get_categories(cls) -> List[Tuple[str, str, int]]:
cls.initialize_definitions()
categories: Dict[str, List[SettingDefinition]] = {}
for definition in cls._definitions.values():
categories.setdefault(definition.category_key, []).append(definition)
result: List[Tuple[str, str, int]] = []
for category_key, items in categories.items():
label = items[0].category_label
result.append((category_key, label, len(items)))
result.sort(key=lambda item: item[1])
return result
@classmethod
def get_settings_for_category(cls, category_key: str) -> List[SettingDefinition]:
cls.initialize_definitions()
filtered = [
definition
for definition in cls._definitions.values()
if definition.category_key == category_key
]
filtered.sort(key=lambda definition: definition.key)
return filtered
@classmethod
def get_definition(cls, key: str) -> SettingDefinition:
cls.initialize_definitions()
return cls._definitions[key]
@classmethod
def has_override(cls, key: str) -> bool:
return key in cls._overrides_raw
@classmethod
def get_current_value(cls, key: str) -> Any:
return getattr(settings, key)
@classmethod
def get_original_value(cls, key: str) -> Any:
return cls._original_values.get(key)
@classmethod
def format_value(cls, value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "✅ Да" if value else "❌ Нет"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, (list, dict, tuple, set)):
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
return str(value)
@classmethod
def format_value_for_list(cls, key: str) -> str:
value = cls.get_current_value(key)
formatted = cls.format_value(value)
if formatted == "":
return formatted
return _truncate(formatted)
@classmethod
def get_choice_options(cls, key: str) -> List[ChoiceOption]:
cls.initialize_definitions()
return cls.CHOICES.get(key, [])
@classmethod
def has_choices(cls, key: str) -> bool:
return bool(cls.get_choice_options(key))
@classmethod
def get_callback_token(cls, key: str) -> str:
cls.initialize_definitions()
return cls._callback_tokens[key]
@classmethod
def resolve_callback_token(cls, token: str) -> str:
cls.initialize_definitions()
return cls._token_to_key[token]
@classmethod
def get_choice_token(cls, key: str, value: Any) -> Optional[str]:
cls.initialize_definitions()
cls._ensure_choice_tokens(key)
return cls._choice_tokens.get(key, {}).get(value)
@classmethod
def resolve_choice_token(cls, key: str, token: str) -> Any:
cls.initialize_definitions()
cls._ensure_choice_tokens(key)
return cls._choice_token_lookup.get(key, {})[token]
@classmethod
def _register_callback_token(cls, key: str) -> None:
if key in cls._callback_tokens:
return
base = hashlib.blake2s(key.encode("utf-8"), digest_size=6).hexdigest()
candidate = base
counter = 1
while candidate in cls._token_to_key and cls._token_to_key[candidate] != key:
suffix = cls._encode_base36(counter)
candidate = f"{base}{suffix}"[:16]
counter += 1
cls._callback_tokens[key] = candidate
cls._token_to_key[candidate] = key
@classmethod
def _ensure_choice_tokens(cls, key: str) -> None:
if key in cls._choice_tokens:
return
options = cls.CHOICES.get(key, [])
value_to_token: Dict[Any, str] = {}
token_to_value: Dict[str, Any] = {}
for index, option in enumerate(options):
token = cls._encode_base36(index)
value_to_token[option.value] = token
token_to_value[token] = option.value
cls._choice_tokens[key] = value_to_token
cls._choice_token_lookup[key] = token_to_value
@staticmethod
def _encode_base36(number: int) -> str:
if number < 0:
raise ValueError("number must be non-negative")
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
if number == 0:
return "0"
result = []
while number:
number, rem = divmod(number, 36)
result.append(alphabet[rem])
return "".join(reversed(result))
@classmethod
async def initialize(cls) -> None:
cls.initialize_definitions()
async with AsyncSessionLocal() as session:
result = await session.execute(select(SystemSetting))
rows = result.scalars().all()
overrides: Dict[str, Optional[str]] = {}
for row in rows:
if row.key in cls._definitions:
overrides[row.key] = row.value
for key, raw_value in overrides.items():
try:
parsed_value = cls.deserialize_value(key, raw_value)
except Exception as error:
logger.error("Не удалось применить настройку %s: %s", key, error)
continue
cls._overrides_raw[key] = raw_value
cls._apply_to_settings(key, parsed_value)
await cls._sync_default_web_api_token()
@classmethod
async def reload(cls) -> None:
cls._overrides_raw.clear()
await cls.initialize()
@classmethod
def deserialize_value(cls, key: str, raw_value: Optional[str]) -> Any:
if raw_value is None:
return None
definition = cls.get_definition(key)
python_type = definition.python_type
if python_type is bool:
value_lower = raw_value.strip().lower()
if value_lower in {"1", "true", "on", "yes", "да"}:
return True
if value_lower in {"0", "false", "off", "no", "нет"}:
return False
raise ValueError(f"Неверное булево значение: {raw_value}")
if python_type is int:
return int(raw_value)
if python_type is float:
return float(raw_value)
return raw_value
@classmethod
def serialize_value(cls, key: str, value: Any) -> Optional[str]:
if value is None:
return None
definition = cls.get_definition(key)
python_type = definition.python_type
if python_type is bool:
return "true" if value else "false"
if python_type in {int, float}:
return str(value)
return str(value)
@classmethod
def parse_user_value(cls, key: str, user_input: str) -> Any:
definition = cls.get_definition(key)
text = (user_input or "").strip()
if text.lower() in {"отмена", "cancel"}:
raise ValueError("Ввод отменен пользователем")
if definition.is_optional and text.lower() in {"none", "null", "пусто", ""}:
return None
python_type = definition.python_type
if python_type is bool:
lowered = text.lower()
if lowered in {"1", "true", "on", "yes", "да", "вкл", "enable", "enabled"}:
return True
if lowered in {"0", "false", "off", "no", "нет", "выкл", "disable", "disabled"}:
return False
raise ValueError("Введите 'true' или 'false' (или 'да'/'нет')")
if python_type is int:
parsed_value: Any = int(text)
elif python_type is float:
parsed_value = float(text.replace(",", "."))
else:
parsed_value = text
choices = cls.get_choice_options(key)
if choices:
allowed_values = {option.value for option in choices}
if python_type is str:
lowered_map = {
str(option.value).lower(): option.value for option in choices
}
normalized = lowered_map.get(str(parsed_value).lower())
if normalized is not None:
parsed_value = normalized
elif parsed_value not in allowed_values:
readable = ", ".join(
f"{option.label} ({cls.format_value(option.value)})" for option in choices
)
raise ValueError(f"Доступные значения: {readable}")
elif parsed_value not in allowed_values:
readable = ", ".join(
f"{option.label} ({cls.format_value(option.value)})" for option in choices
)
raise ValueError(f"Доступные значения: {readable}")
return parsed_value
@classmethod
async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None:
raw_value = cls.serialize_value(key, value)
await upsert_system_setting(db, key, raw_value)
cls._overrides_raw[key] = raw_value
cls._apply_to_settings(key, value)
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
await cls._sync_default_web_api_token()
@classmethod
async def reset_value(cls, db: AsyncSession, key: str) -> None:
await delete_system_setting(db, key)
cls._overrides_raw.pop(key, None)
original = cls.get_original_value(key)
cls._apply_to_settings(key, original)
if key in {"WEB_API_DEFAULT_TOKEN", "WEB_API_DEFAULT_TOKEN_NAME"}:
await cls._sync_default_web_api_token()
@classmethod
def _apply_to_settings(cls, key: str, value: Any) -> None:
try:
setattr(settings, key, value)
except Exception as error:
logger.error("Не удалось применить значение %s=%s: %s", key, value, error)
@staticmethod
async def _sync_default_web_api_token() -> None:
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
if not default_token:
return
success = await ensure_default_web_api_token()
if not success:
logger.warning(
"Не удалось синхронизировать бутстрап токен веб-API после обновления настроек",
)
@classmethod
def get_setting_summary(cls, key: str) -> Dict[str, Any]:
definition = cls.get_definition(key)
current = cls.get_current_value(key)
original = cls.get_original_value(key)
has_override = cls.has_override(key)
return {
"key": key,
"name": definition.display_name,
"current": cls.format_value(current),
"original": cls.format_value(original),
"type": definition.type_label,
"category_key": definition.category_key,
"category_label": definition.category_label,
"has_override": has_override,
}
bot_configuration_service = BotConfigurationService
+17 -8
View File
@@ -18,7 +18,7 @@ from app.database.models import (
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
SentNotification, PromoGroup, MulenPayPayment, Pal24Payment,
AdvertisingCampaign
AdvertisingCampaign, PaymentMethod
)
from app.config import settings
@@ -203,7 +203,14 @@ class UserService:
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}")
success = True
else:
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
success = await subtract_user_balance(
db,
user,
abs(amount_kopeks),
description,
create_transaction=True,
payment_method=PaymentMethod.MANUAL,
)
if success:
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}")
@@ -231,15 +238,17 @@ class UserService:
db: AsyncSession,
user_id: int,
promo_group_id: int
) -> Tuple[bool, Optional[User], Optional[PromoGroup]]:
) -> Tuple[bool, Optional[User], Optional[PromoGroup], Optional[PromoGroup]]:
try:
user = await get_user_by_id(db, user_id)
if not user:
return False, None, None
return False, None, None, None
old_group = user.promo_group
promo_group = await get_promo_group_by_id(db, promo_group_id)
if not promo_group:
return False, None, None
return False, None, None, old_group
user.promo_group_id = promo_group.id
user.promo_group = promo_group
@@ -254,12 +263,12 @@ class UserService:
promo_group.name,
)
return True, user, promo_group
return True, user, promo_group, old_group
except Exception as e:
await db.rollback()
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
return False, None, None
return False, None, None, None
async def block_user(
self,
@@ -360,7 +369,7 @@ class UserService:
if delete_mode == "delete":
# Удаляем пользователя из панели Remnawave
async with remnawave_service.api as api:
async with remnawave_service.get_api_client() as api:
delete_success = await api.delete_user(user.remnawave_uuid)
if delete_success:
logger.info(f"✅ RemnaWave пользователь {user.remnawave_uuid} удален из панели")
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import secrets
from datetime import datetime
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud import web_api_token as crud
from app.database.models import WebApiToken
from app.database.universal_migration import ensure_default_web_api_token
from app.utils.security import generate_api_token, hash_api_token
class WebApiTokenService:
"""Сервис для управления токенами административного веб-API."""
def __init__(self):
self.algorithm = settings.WEB_API_TOKEN_HASH_ALGORITHM or "sha256"
def hash_token(self, token: str) -> str:
return hash_api_token(token, self.algorithm) # type: ignore[arg-type]
async def authenticate(
self,
db: AsyncSession,
token_value: str,
*,
remote_ip: Optional[str] = None,
) -> Optional[WebApiToken]:
normalized_value = token_value.strip()
if not normalized_value:
return None
async def _load_token(value: str) -> Optional[WebApiToken]:
token_hash = self.hash_token(value)
return await crud.get_token_by_hash(db, token_hash)
token = await _load_token(normalized_value)
if not token:
default_token = (settings.WEB_API_DEFAULT_TOKEN or "").strip()
if default_token and secrets.compare_digest(default_token, normalized_value):
await ensure_default_web_api_token()
token = await _load_token(default_token)
if not token or not token.is_active:
return None
if token.expires_at and token.expires_at < datetime.utcnow():
return None
token.last_used_at = datetime.utcnow()
if remote_ip:
token.last_used_ip = remote_ip
await db.flush()
return token
async def create_token(
self,
db: AsyncSession,
*,
name: str,
description: Optional[str] = None,
expires_at: Optional[datetime] = None,
created_by: Optional[str] = None,
token_value: Optional[str] = None,
) -> Tuple[str, WebApiToken]:
plain_token = token_value or generate_api_token()
token_hash = self.hash_token(plain_token)
token = await crud.create_token(
db,
name=name,
token_hash=token_hash,
token_prefix=plain_token[:12],
description=description,
expires_at=expires_at,
created_by=created_by,
)
return plain_token, token
async def revoke_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
token.is_active = False
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
async def activate_token(self, db: AsyncSession, token: WebApiToken) -> WebApiToken:
token.is_active = True
token.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(token)
return token
web_api_token_service = WebApiTokenService()
+5
View File
@@ -1,6 +1,7 @@
from aiogram.fsm.state import State, StatesGroup
class RegistrationStates(StatesGroup):
waiting_for_language = State()
waiting_for_rules_accept = State()
waiting_for_referral_code = State()
@@ -123,6 +124,10 @@ class AdminTicketStates(StatesGroup):
class SupportSettingsStates(StatesGroup):
waiting_for_desc = State()
class BotConfigStates(StatesGroup):
waiting_for_value = State()
class AutoPayStates(StatesGroup):
setting_autopay_days = State()
confirming_autopay_toggle = State()
+13 -7
View File
@@ -8,22 +8,28 @@ from app.states import RegistrationStates
def is_registration_process(event: TelegramObject, current_state: Optional[str]) -> bool:
registration_states = [
RegistrationStates.waiting_for_language.state,
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_referral_code.state
]
registration_callbacks = [
"rules_accept",
"rules_decline",
"rules_accept",
"rules_decline",
"referral_skip"
]
language_select_prefix = "language_select:"
if current_state in registration_states:
return True
if (isinstance(event, CallbackQuery)
and event.data
and event.data in registration_callbacks):
if (isinstance(event, CallbackQuery)
and event.data
and (
event.data in registration_callbacks
or event.data.startswith(language_select_prefix)
)):
return True
return False
return False
+1
View File
@@ -27,6 +27,7 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
pass
if LOGO_PATH.exists():
try:
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
except Exception:
# Фоллбек, если Telegram ругается на caption: отправим как текст
+2 -2
View File
@@ -48,8 +48,8 @@ def get_available_payment_methods() -> List[Dict[str, str]]:
if settings.is_pal24_enabled():
methods.append({
"id": "pal24",
"name": "Банковская карта",
"icon": "💳",
"name": "СБП",
"icon": "🏦",
"description": "через PayPalych",
"callback": "topup_pal24"
})
+7
View File
@@ -50,6 +50,13 @@ def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
discount_value = amount * clamped_percent // 100
discounted_amount = amount - discount_value
# Round the discounted price up to the nearest full ruble (100 kopeks)
# to avoid undercharging users because of fractional kopeks.
if discount_value >= 100 and discounted_amount % 100:
discounted_amount += 100 - (discounted_amount % 100)
discounted_amount = min(discounted_amount, amount)
discount_value = amount - discounted_amount
logger.debug(
"Применена скидка %s%%: %s%s (скидка %s)",
clamped_percent,
+28
View File
@@ -0,0 +1,28 @@
"""Утилиты безопасности и генерации ключей."""
from __future__ import annotations
import hashlib
import secrets
from typing import Literal
HashAlgorithm = Literal["sha256", "sha384", "sha512"]
def hash_api_token(token: str, algorithm: HashAlgorithm = "sha256") -> str:
"""Возвращает хеш токена в формате hex."""
normalized = (algorithm or "sha256").lower()
if normalized not in {"sha256", "sha384", "sha512"}:
raise ValueError(f"Unsupported hash algorithm: {algorithm}")
digest = getattr(hashlib, normalized)
return digest(token.encode("utf-8")).hexdigest()
def generate_api_token(length: int = 48) -> str:
"""Генерирует криптографически стойкий токен."""
length = max(24, min(length, 128))
return secrets.token_urlsafe(length)
__all__ = ["hash_api_token", "generate_api_token", "HashAlgorithm"]
+63 -2
View File
@@ -1,9 +1,11 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select, delete
from urllib.parse import quote, urlparse, urlunparse
from sqlalchemy import select, delete, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Subscription, User
from app.config import settings
logger = logging.getLogger(__name__)
@@ -93,5 +95,64 @@ async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int:
await db.commit()
logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок")
return total_deleted
def get_display_subscription_link(subscription: Optional[Subscription]) -> Optional[str]:
if not subscription:
return None
base_link = getattr(subscription, "subscription_url", None)
if settings.is_happ_cryptolink_mode():
crypto_link = getattr(subscription, "subscription_crypto_link", None)
return crypto_link or base_link
return base_link
def get_happ_cryptolink_redirect_link(subscription_link: Optional[str]) -> Optional[str]:
if not subscription_link:
return None
template = settings.get_happ_cryptolink_redirect_template()
if not template:
return None
encoded_link = quote(subscription_link, safe="")
replacements = {
"{subscription_link}": encoded_link,
"{link}": encoded_link,
"{subscription_link_raw}": subscription_link,
"{link_raw}": subscription_link,
}
replaced = False
for placeholder, value in replacements.items():
if placeholder in template:
template = template.replace(placeholder, value)
replaced = True
if replaced:
return template
if template.endswith(("=", "?", "&")):
return f"{template}{encoded_link}"
return f"{template}{encoded_link}"
def convert_subscription_link_to_happ_scheme(subscription_link: Optional[str]) -> Optional[str]:
if not subscription_link:
return None
parsed_link = urlparse(subscription_link)
if parsed_link.scheme.lower() == "happ":
return subscription_link
if not parsed_link.scheme:
return subscription_link
return urlunparse(parsed_link._replace(scheme="happ"))
+17
View File
@@ -143,6 +143,23 @@ def sanitize_html(text: str) -> str:
return text
def sanitize_telegram_name(name: Optional[str]) -> Optional[str]:
"""Санитизация Telegram-имени для безопасной вставки в HTML и хранения.
Заменяет угловые скобки и амперсанд на безопасные визуальные аналоги.
"""
if not name:
return name
try:
return (
name.replace('<', '')
.replace('>', '')
.replace('&', '')
.strip()
)
except Exception:
return name
def validate_device_count(count: Union[str, int]) -> Optional[int]:
try:
count_int = int(count)
+5
View File
@@ -0,0 +1,5 @@
"""Пакет административного веб-API."""
from .app import create_web_api_app
from .server import WebAPIServer
__all__ = ["create_web_api_app", "WebAPIServer"]
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from .middleware import RequestLoggingMiddleware
from .routes import (
broadcasts,
backups,
campaigns,
config,
health,
promocodes,
promo_groups,
remnawave,
stats,
subscriptions,
tickets,
tokens,
transactions,
users,
)
OPENAPI_TAGS = [
{
"name": "health",
"description": "Мониторинг состояния административного API и связанных сервисов.",
},
{
"name": "stats",
"description": "Сводные показатели по пользователям, подпискам и платежам.",
},
{
"name": "settings",
"description": "Получение и изменение конфигурации бота из административной панели.",
},
{
"name": "users",
"description": "Управление пользователями, балансом и статусами подписок.",
},
{
"name": "subscriptions",
"description": "Создание, продление и настройка подписок бота.",
},
{
"name": "support",
"description": "Работа с тикетами поддержки, приоритетами и ограничениями на ответы.",
},
{
"name": "transactions",
"description": "История финансовых операций и пополнений баланса.",
},
{
"name": "promo-groups",
"description": "Создание и управление промо-группами и их участниками.",
},
{
"name": "auth",
"description": "Управление токенами доступа к административному API.",
},
{
"name": "remnawave",
"description": (
"Интеграция с RemnaWave: статус панели, управление нодами, сквадами и синхронизацией "
"данных между ботом и панелью."
),
},
]
def create_web_api_app() -> FastAPI:
docs_config = settings.get_web_api_docs_config()
app = FastAPI(
title=settings.WEB_API_TITLE,
version=settings.WEB_API_VERSION,
docs_url=docs_config.get("docs_url"),
redoc_url=docs_config.get("redoc_url"),
openapi_url=docs_config.get("openapi_url"),
openapi_tags=OPENAPI_TAGS,
swagger_ui_parameters={"persistAuthorization": True},
)
allowed_origins = settings.get_web_api_allowed_origins()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if allowed_origins == ["*"] else allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if settings.WEB_API_REQUEST_LOGGING:
app.add_middleware(RequestLoggingMiddleware)
app.include_router(health.router)
app.include_router(stats.router, prefix="/stats", tags=["stats"])
app.include_router(config.router, prefix="/settings", tags=["settings"])
app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(subscriptions.router, prefix="/subscriptions", tags=["subscriptions"])
app.include_router(tickets.router, prefix="/tickets", tags=["support"])
app.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
app.include_router(promo_groups.router, prefix="/promo-groups", tags=["promo-groups"])
app.include_router(promocodes.router, prefix="/promo-codes", tags=["promo-codes"])
app.include_router(broadcasts.router, prefix="/broadcasts", tags=["broadcasts"])
app.include_router(backups.router, prefix="/backups", tags=["backups"])
app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
return app
+1
View File
@@ -0,0 +1 @@
"""Background utilities for Web API."""
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import asyncio
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Optional
from app.services.backup_service import backup_service
@dataclass(slots=True)
class BackupTaskState:
task_id: str
status: str = "queued"
message: Optional[str] = None
file_path: Optional[str] = None
created_by: Optional[int] = None
created_at: datetime = field(default_factory=datetime.utcnow)
updated_at: datetime = field(default_factory=datetime.utcnow)
class BackupTaskManager:
def __init__(self) -> None:
self._tasks: dict[str, BackupTaskState] = {}
self._lock = asyncio.Lock()
async def enqueue(self, *, created_by: Optional[int]) -> BackupTaskState:
task_id = uuid.uuid4().hex
state = BackupTaskState(task_id=task_id, created_by=created_by)
async with self._lock:
self._tasks[task_id] = state
asyncio.create_task(self._run_task(state))
return state
async def _run_task(self, state: BackupTaskState) -> None:
state.status = "running"
state.updated_at = datetime.utcnow()
try:
success, message, file_path = await backup_service.create_backup(
created_by=state.created_by
)
state.message = message
state.file_path = file_path
state.status = "completed" if success else "failed"
except Exception as exc: # noqa: BLE001
state.status = "failed"
state.message = f"Unexpected error: {exc}"
finally:
state.updated_at = datetime.utcnow()
async def get(self, task_id: str) -> Optional[BackupTaskState]:
async with self._lock:
return self._tasks.get(task_id)
async def list(self, *, active_only: bool = False) -> list[BackupTaskState]:
async with self._lock:
states = list(self._tasks.values())
if active_only:
return [
state
for state in states
if state.status in {"queued", "running"}
]
return states
backup_task_manager = BackupTaskManager()
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
from typing import AsyncGenerator
from fastapi import Depends, HTTPException, Request, Security, status
from fastapi.security import APIKeyHeader
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import AsyncSessionLocal
from app.database.models import WebApiToken
from app.services.web_api_token_service import web_api_token_service
api_key_header_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def require_api_token(
request: Request,
api_key_header: str | None = Security(api_key_header_scheme),
db: AsyncSession = Depends(get_db_session),
) -> WebApiToken:
api_key = api_key_header
if not api_key:
authorization = request.headers.get("Authorization")
if authorization:
scheme, _, credentials = authorization.partition(" ")
if scheme.lower() == "bearer" and credentials:
api_key = credentials
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key",
)
token = await web_api_token_service.authenticate(
db,
api_key,
remote_ip=request.client.host if request.client else None,
)
if not token:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired API key",
)
await db.commit()
return token
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import logging
from time import monotonic
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger("web_api")
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""Логирование входящих запросов в административный API."""
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
start = monotonic()
response: Response | None = None
try:
response = await call_next(request)
return response
finally:
duration_ms = (monotonic() - start) * 1000
status = response.status_code if response else "error"
logger.info(
"%s %s -> %s (%.2f ms)",
request.method,
request.url.path,
status,
duration_ms,
)
+25
View File
@@ -0,0 +1,25 @@
from . import (
config,
health,
promo_groups,
remnawave,
stats,
subscriptions,
tickets,
tokens,
transactions,
users,
)
__all__ = [
"config",
"health",
"promo_groups",
"remnawave",
"stats",
"subscriptions",
"tickets",
"tokens",
"transactions",
"users",
]
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query, Security, status
from app.services.backup_service import backup_service
from ..background.backup_tasks import backup_task_manager
from ..dependencies import require_api_token
from ..schemas.backups import (
BackupCreateResponse,
BackupInfo,
BackupListResponse,
BackupStatusResponse,
BackupTaskInfo,
BackupTaskListResponse,
)
router = APIRouter()
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
if value.endswith("Z"):
value = value.replace("Z", "+00:00")
return datetime.fromisoformat(value)
except ValueError:
return None
def _to_int(value: Any) -> Optional[int]:
try:
return int(value)
except (TypeError, ValueError):
return None
def _serialize_backup(raw: dict) -> BackupInfo:
timestamp = _parse_datetime(raw.get("timestamp"))
tables_count = _to_int(raw.get("tables_count"))
total_records = _to_int(raw.get("total_records"))
file_size_bytes = _to_int(raw.get("file_size_bytes")) or 0
file_size_mb = raw.get("file_size_mb")
try:
file_size_mb = float(file_size_mb)
except (TypeError, ValueError):
file_size_mb = round(file_size_bytes / 1024 / 1024, 2)
created_by = _to_int(raw.get("created_by"))
return BackupInfo(
filename=str(raw.get("filename")),
filepath=str(raw.get("filepath")),
timestamp=timestamp,
tables_count=tables_count,
total_records=total_records,
compressed=bool(raw.get("compressed", False)),
file_size_bytes=file_size_bytes,
file_size_mb=float(file_size_mb),
created_by=created_by,
database_type=raw.get("database_type"),
version=raw.get("version"),
error=raw.get("error"),
)
@router.post(
"",
response_model=BackupCreateResponse,
status_code=status.HTTP_202_ACCEPTED,
summary="Запустить создание резервной копии",
)
async def create_backup_endpoint(
token: Any = Security(require_api_token),
) -> BackupCreateResponse:
created_by = getattr(token, "id", None)
state = await backup_task_manager.enqueue(created_by=created_by)
return BackupCreateResponse(task_id=state.task_id, status=state.status)
@router.get(
"",
response_model=BackupListResponse,
summary="Список резервных копий",
)
async def list_backups(
_: Any = Security(require_api_token),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> BackupListResponse:
backups = await backup_service.get_backup_list()
total = len(backups)
slice_backups = backups[offset : offset + limit]
items = [_serialize_backup(raw) for raw in slice_backups]
return BackupListResponse(
items=items,
total=total,
limit=limit,
offset=offset,
)
@router.get(
"/status/{task_id}",
response_model=BackupStatusResponse,
summary="Статус создания резервной копии",
)
async def get_backup_status(
task_id: str,
_: Any = Security(require_api_token),
) -> BackupStatusResponse:
state = await backup_task_manager.get(task_id)
if not state:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Task not found")
return BackupStatusResponse(
task_id=state.task_id,
status=state.status,
message=state.message,
file_path=state.file_path,
created_by=state.created_by,
created_at=state.created_at,
updated_at=state.updated_at,
)
@router.get(
"/tasks",
response_model=BackupTaskListResponse,
summary="Список фоновых задач бекапов",
)
async def list_backup_tasks(
_: Any = Security(require_api_token),
active_only: bool = Query(False, description="Вернуть только активные задачи"),
) -> BackupTaskListResponse:
states = await backup_task_manager.list(active_only=active_only)
items = [
BackupTaskInfo(
task_id=state.task_id,
status=state.status,
message=state.message,
file_path=state.file_path,
created_by=state.created_by,
created_at=state.created_at,
updated_at=state.updated_at,
)
for state in states
]
return BackupTaskListResponse(items=items, total=len(items))
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import BroadcastHistory
from app.services.broadcast_service import (
BroadcastConfig,
BroadcastMediaConfig,
broadcast_service,
)
from ..dependencies import get_db_session, require_api_token
from ..schemas.broadcasts import (
BroadcastCreateRequest,
BroadcastListResponse,
BroadcastResponse,
)
router = APIRouter()
def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
return BroadcastResponse(
id=broadcast.id,
target_type=broadcast.target_type,
message_text=broadcast.message_text,
has_media=broadcast.has_media,
media_type=broadcast.media_type,
media_file_id=broadcast.media_file_id,
media_caption=broadcast.media_caption,
total_count=broadcast.total_count,
sent_count=broadcast.sent_count,
failed_count=broadcast.failed_count,
status=broadcast.status,
admin_id=broadcast.admin_id,
admin_name=broadcast.admin_name,
created_at=broadcast.created_at,
completed_at=broadcast.completed_at,
)
@router.post("", response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_broadcast(
payload: BroadcastCreateRequest,
token: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> BroadcastResponse:
message_text = payload.message_text.strip()
if not message_text:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Message text must not be empty")
media_payload = payload.media
broadcast = BroadcastHistory(
target_type=payload.target,
message_text=message_text,
has_media=media_payload is not None,
media_type=media_payload.type if media_payload else None,
media_file_id=media_payload.file_id if media_payload else None,
media_caption=media_payload.caption if media_payload else None,
total_count=0,
sent_count=0,
failed_count=0,
status="queued",
admin_id=None,
admin_name=getattr(token, "name", None) or getattr(token, "created_by", None),
)
db.add(broadcast)
await db.commit()
await db.refresh(broadcast)
media_config = None
if media_payload:
media_config = BroadcastMediaConfig(
type=media_payload.type,
file_id=media_payload.file_id,
caption=media_payload.caption or message_text,
)
config = BroadcastConfig(
target=payload.target,
message_text=message_text,
selected_buttons=payload.selected_buttons,
media=media_config,
initiator_name=getattr(token, "name", None) or getattr(token, "created_by", None),
)
await broadcast_service.start_broadcast(broadcast.id, config)
await db.refresh(broadcast)
return _serialize_broadcast(broadcast)
@router.get("", response_model=BroadcastListResponse)
async def list_broadcasts(
_: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> BroadcastListResponse:
total = await db.scalar(select(func.count(BroadcastHistory.id))) or 0
result = await db.execute(
select(BroadcastHistory)
.order_by(BroadcastHistory.created_at.desc())
.offset(offset)
.limit(limit)
)
broadcasts = result.scalars().all()
return BroadcastListResponse(
items=[_serialize_broadcast(item) for item in broadcasts],
total=int(total),
limit=limit,
offset=offset,
)
@router.post("/{broadcast_id}/stop", response_model=BroadcastResponse)
async def stop_broadcast(
broadcast_id: int,
_: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> BroadcastResponse:
broadcast = await db.get(BroadcastHistory, broadcast_id)
if not broadcast:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Broadcast not found")
if broadcast.status not in {"queued", "in_progress", "cancelling"}:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Broadcast is not running")
is_running = await broadcast_service.request_stop(broadcast_id)
if is_running:
broadcast.status = "cancelling"
else:
broadcast.status = "cancelled"
broadcast.completed_at = datetime.utcnow()
await db.commit()
await db.refresh(broadcast)
return _serialize_broadcast(broadcast)
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.campaign import (
create_campaign,
delete_campaign,
get_campaign_by_id,
get_campaigns_count,
get_campaigns_list,
update_campaign,
)
from ..dependencies import get_db_session, require_api_token
from ..schemas.campaigns import (
CampaignCreateRequest,
CampaignListResponse,
CampaignResponse,
CampaignUpdateRequest,
)
router = APIRouter()
def _serialize_campaign(campaign) -> CampaignResponse:
registrations_attr = None
if isinstance(getattr(campaign, "__dict__", None), dict):
registrations_attr = campaign.__dict__.get("registrations")
registrations = registrations_attr or []
squads = list(campaign.subscription_squads or [])
return CampaignResponse(
id=campaign.id,
name=campaign.name,
start_parameter=campaign.start_parameter,
bonus_type=campaign.bonus_type,
balance_bonus_kopeks=campaign.balance_bonus_kopeks or 0,
balance_bonus_rubles=round((campaign.balance_bonus_kopeks or 0) / 100, 2),
subscription_duration_days=campaign.subscription_duration_days,
subscription_traffic_gb=campaign.subscription_traffic_gb,
subscription_device_limit=campaign.subscription_device_limit,
subscription_squads=squads,
is_active=campaign.is_active,
created_by=campaign.created_by,
created_at=campaign.created_at,
updated_at=campaign.updated_at,
registrations_count=len(registrations),
)
@router.post(
"",
response_model=CampaignResponse,
status_code=status.HTTP_201_CREATED,
summary="Создать рекламную кампанию",
)
async def create_campaign_endpoint(
payload: CampaignCreateRequest,
token: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> CampaignResponse:
created_by = getattr(token, "id", None)
try:
campaign = await create_campaign(
db,
name=payload.name,
start_parameter=payload.start_parameter,
bonus_type=payload.bonus_type,
created_by=created_by,
balance_bonus_kopeks=payload.balance_bonus_kopeks,
subscription_duration_days=payload.subscription_duration_days,
subscription_traffic_gb=payload.subscription_traffic_gb,
subscription_device_limit=payload.subscription_device_limit,
subscription_squads=payload.subscription_squads,
is_active=payload.is_active,
)
except IntegrityError as exc: # duplicate start_parameter
await db.rollback()
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Campaign with this start_parameter already exists",
) from exc
return _serialize_campaign(campaign)
@router.get(
"",
response_model=CampaignListResponse,
summary="Список рекламных кампаний",
)
async def list_campaigns(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
include_inactive: bool = Query(True, description="Включать неактивные кампании"),
) -> CampaignListResponse:
total = await get_campaigns_count(db, is_active=None if include_inactive else True)
campaigns = await get_campaigns_list(
db,
offset=offset,
limit=limit,
include_inactive=include_inactive,
)
return CampaignListResponse(
items=[_serialize_campaign(campaign) for campaign in campaigns],
total=total,
limit=limit,
offset=offset,
)
@router.delete(
"/{campaign_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Удалить рекламную кампанию",
)
async def delete_campaign_endpoint(
campaign_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
):
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Campaign not found")
await delete_campaign(db, campaign)
return None
@router.patch(
"/{campaign_id}",
response_model=CampaignResponse,
summary="Обновить рекламную кампанию",
)
async def update_campaign_endpoint(
campaign_id: int,
payload: CampaignUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> CampaignResponse:
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Campaign not found")
update_fields = payload.dict(exclude_unset=True)
if not update_fields:
return _serialize_campaign(campaign)
try:
campaign = await update_campaign(db, campaign, **update_fields)
except IntegrityError as exc:
await db.rollback()
if "start_parameter" in str(exc.orig):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Campaign with this start_parameter already exists",
) from exc
raise
return _serialize_campaign(campaign)
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.system_settings_service import bot_configuration_service
from ..dependencies import get_db_session, require_api_token
from ..schemas.config import (
SettingCategoryRef,
SettingCategorySummary,
SettingChoice,
SettingDefinition,
SettingUpdateRequest,
)
router = APIRouter()
def _coerce_value(key: str, value: Any) -> Any:
definition = bot_configuration_service.get_definition(key)
if value is None:
if definition.is_optional:
return None
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Value is required")
python_type = definition.python_type
try:
if python_type is bool:
if isinstance(value, bool):
normalized = value
elif isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"true", "1", "yes", "on", "да"}:
normalized = True
elif lowered in {"false", "0", "no", "off", "нет"}:
normalized = False
else:
raise ValueError("invalid bool")
else:
raise ValueError("invalid bool")
elif python_type is int:
normalized = int(value)
elif python_type is float:
normalized = float(value)
else:
normalized = str(value)
except ValueError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid value type") from None
choices = bot_configuration_service.get_choice_options(key)
if choices:
allowed_values = {option.value for option in choices}
if normalized not in allowed_values:
readable = ", ".join(bot_configuration_service.format_value(opt.value) for opt in choices)
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Value must be one of: {readable}",
)
return normalized
def _serialize_definition(definition, include_choices: bool = True) -> SettingDefinition:
current = bot_configuration_service.get_current_value(definition.key)
original = bot_configuration_service.get_original_value(definition.key)
has_override = bot_configuration_service.has_override(definition.key)
choices: list[SettingChoice] = []
if include_choices:
choices = [
SettingChoice(
value=option.value,
label=option.label,
description=option.description,
)
for option in bot_configuration_service.get_choice_options(definition.key)
]
return SettingDefinition(
key=definition.key,
name=definition.display_name,
category=SettingCategoryRef(
key=definition.category_key,
label=definition.category_label,
),
type=definition.type_label,
is_optional=definition.is_optional,
current=current,
original=original,
has_override=has_override,
choices=choices,
)
@router.get("/categories", response_model=list[SettingCategorySummary])
async def list_categories(
_: object = Security(require_api_token),
) -> list[SettingCategorySummary]:
categories = bot_configuration_service.get_categories()
return [
SettingCategorySummary(key=key, label=label, items=count)
for key, label, count in categories
]
@router.get("", response_model=list[SettingDefinition])
async def list_settings(
_: object = Security(require_api_token),
category: Optional[str] = Query(default=None, alias="category_key"),
) -> list[SettingDefinition]:
items: list[SettingDefinition] = []
if category:
definitions = bot_configuration_service.get_settings_for_category(category)
items.extend(_serialize_definition(defn) for defn in definitions)
return items
for category_key, _, _ in bot_configuration_service.get_categories():
definitions = bot_configuration_service.get_settings_for_category(category_key)
items.extend(_serialize_definition(defn) for defn in definitions)
return items
@router.get("/{key}", response_model=SettingDefinition)
async def get_setting(
key: str,
_: object = Security(require_api_token),
) -> SettingDefinition:
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error: # pragma: no cover - защита от некорректного ключа
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
return _serialize_definition(definition)
@router.put("/{key}", response_model=SettingDefinition)
async def update_setting(
key: str,
payload: SettingUpdateRequest,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SettingDefinition:
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
value = _coerce_value(key, payload.value)
await bot_configuration_service.set_value(db, key, value)
await db.commit()
return _serialize_definition(definition)
@router.delete("/{key}", response_model=SettingDefinition)
async def reset_setting(
key: str,
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SettingDefinition:
try:
definition = bot_configuration_service.get_definition(key)
except KeyError as error:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error
await bot_configuration_service.reset_value(db, key)
await db.commit()
return _serialize_definition(definition)
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from fastapi import APIRouter, Security
from app.config import settings
from app.services.version_service import version_service
from ..dependencies import require_api_token
from ..schemas.health import HealthCheckResponse, HealthFeatureFlags
router = APIRouter()
@router.get("/health", tags=["health"], response_model=HealthCheckResponse)
async def health_check(_: object = Security(require_api_token)) -> HealthCheckResponse:
return HealthCheckResponse(
status="ok",
api_version=settings.WEB_API_VERSION,
bot_version=version_service.current_version,
features=HealthFeatureFlags(
monitoring=settings.MONITORING_INTERVAL > 0,
maintenance=True,
reporting=True,
webhooks=bool(settings.WEBHOOK_URL),
),
)
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Response, Security, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.promo_group import (
count_promo_group_members,
count_promo_groups,
create_promo_group,
delete_promo_group,
get_promo_group_by_id,
get_promo_groups_with_counts,
update_promo_group,
)
from app.database.models import PromoGroup
from ..dependencies import get_db_session, require_api_token
from ..schemas.promo_groups import (
PromoGroupCreateRequest,
PromoGroupListResponse,
PromoGroupResponse,
PromoGroupUpdateRequest,
)
router = APIRouter()
def _normalize_period_discounts(group: PromoGroup) -> dict[int, int]:
raw = group.period_discounts or {}
normalized: dict[int, int] = {}
if isinstance(raw, dict):
for key, value in raw.items():
try:
normalized[int(key)] = int(value)
except (TypeError, ValueError):
continue
return normalized
def _serialize(group: PromoGroup, members_count: int = 0) -> PromoGroupResponse:
return PromoGroupResponse(
id=group.id,
name=group.name,
server_discount_percent=group.server_discount_percent,
traffic_discount_percent=group.traffic_discount_percent,
device_discount_percent=group.device_discount_percent,
period_discounts=_normalize_period_discounts(group),
auto_assign_total_spent_kopeks=group.auto_assign_total_spent_kopeks,
apply_discounts_to_addons=group.apply_discounts_to_addons,
is_default=group.is_default,
members_count=members_count,
created_at=group.created_at,
updated_at=group.updated_at,
)
@router.get("", response_model=PromoGroupListResponse)
async def list_promo_groups(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PromoGroupListResponse:
total = await count_promo_groups(db)
groups_with_counts = await get_promo_groups_with_counts(
db,
offset=offset,
limit=limit,
)
return PromoGroupListResponse(
items=[_serialize(group, members_count=count) for group, count in groups_with_counts],
total=total,
limit=limit,
offset=offset,
)
@router.get("/{group_id}", response_model=PromoGroupResponse)
async def get_promo_group(
group_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoGroupResponse:
group = await get_promo_group_by_id(db, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
members_count = await count_promo_group_members(db, group_id)
return _serialize(group, members_count=members_count)
@router.post("", response_model=PromoGroupResponse, status_code=status.HTTP_201_CREATED)
async def create_promo_group_endpoint(
payload: PromoGroupCreateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoGroupResponse:
try:
group = await create_promo_group(
db,
name=payload.name,
server_discount_percent=payload.server_discount_percent,
traffic_discount_percent=payload.traffic_discount_percent,
device_discount_percent=payload.device_discount_percent,
period_discounts=payload.period_discounts,
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
apply_discounts_to_addons=payload.apply_discounts_to_addons,
is_default=payload.is_default,
)
except IntegrityError as exc:
await db.rollback()
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Promo group with this name already exists",
) from exc
return _serialize(group, members_count=0)
@router.patch("/{group_id}", response_model=PromoGroupResponse)
async def update_promo_group_endpoint(
group_id: int,
payload: PromoGroupUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoGroupResponse:
group = await get_promo_group_by_id(db, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
try:
group = await update_promo_group(
db,
group,
name=payload.name,
server_discount_percent=payload.server_discount_percent,
traffic_discount_percent=payload.traffic_discount_percent,
device_discount_percent=payload.device_discount_percent,
period_discounts=payload.period_discounts,
auto_assign_total_spent_kopeks=payload.auto_assign_total_spent_kopeks,
apply_discounts_to_addons=payload.apply_discounts_to_addons,
is_default=payload.is_default,
)
except IntegrityError as exc:
await db.rollback()
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Promo group with this name already exists",
) from exc
members_count = await count_promo_group_members(db, group_id)
return _serialize(group, members_count=members_count)
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_promo_group_endpoint(
group_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
group = await get_promo_group_by_id(db, group_id)
if not group:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo group not found")
success = await delete_promo_group(db, group)
if not success:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Cannot delete default promo group")
return Response(status_code=status.HTTP_204_NO_CONTENT)
+301
View File
@@ -0,0 +1,301 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.promocode import (
create_promocode,
delete_promocode,
get_promocode_by_code,
get_promocode_statistics,
get_promocodes_count,
get_promocodes_list,
update_promocode,
)
from app.database.models import PromoCode, PromoCodeType, PromoCodeUse
from ..dependencies import get_db_session, require_api_token
from ..schemas.promocodes import (
PromoCodeCreateRequest,
PromoCodeDetailResponse,
PromoCodeListResponse,
PromoCodeRecentUse,
PromoCodeResponse,
PromoCodeUpdateRequest,
)
router = APIRouter()
def _normalize_datetime(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is not None and value.utcoffset() is not None:
return value.astimezone(timezone.utc).replace(tzinfo=None)
if value.tzinfo is not None:
return value.replace(tzinfo=None)
return value
def _serialize_promocode(promocode: PromoCode) -> PromoCodeResponse:
promo_type = PromoCodeType(promocode.type)
return PromoCodeResponse(
id=promocode.id,
code=promocode.code,
type=promo_type,
balance_bonus_kopeks=promocode.balance_bonus_kopeks,
balance_bonus_rubles=round(promocode.balance_bonus_kopeks / 100, 2),
subscription_days=promocode.subscription_days,
max_uses=promocode.max_uses,
current_uses=promocode.current_uses,
uses_left=promocode.uses_left,
is_active=promocode.is_active,
is_valid=promocode.is_valid,
valid_from=promocode.valid_from,
valid_until=promocode.valid_until,
created_by=promocode.created_by,
created_at=promocode.created_at,
updated_at=promocode.updated_at,
)
def _serialize_recent_use(use: PromoCodeUse) -> PromoCodeRecentUse:
return PromoCodeRecentUse(
id=use.id,
user_id=use.user_id,
user_username=getattr(use, "user_username", None),
user_full_name=getattr(use, "user_full_name", None),
user_telegram_id=getattr(use, "user_telegram_id", None),
used_at=use.used_at,
)
def _validate_create_payload(payload: PromoCodeCreateRequest) -> None:
code = payload.code.strip()
if not code:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Code must not be empty")
normalized_valid_from = _normalize_datetime(payload.valid_from)
normalized_valid_until = _normalize_datetime(payload.valid_until)
if payload.type == PromoCodeType.BALANCE and payload.balance_bonus_kopeks <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Balance bonus must be positive for balance promo codes")
if payload.type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION} and payload.subscription_days <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription days must be positive for this promo code type")
if normalized_valid_from and normalized_valid_until and normalized_valid_from > normalized_valid_until:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_from cannot be greater than valid_until")
def _validate_update_payload(payload: PromoCodeUpdateRequest, promocode: PromoCode) -> None:
if payload.code is not None and not payload.code.strip():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Code must not be empty")
if payload.type is not None:
new_type = payload.type
else:
new_type = PromoCodeType(promocode.type)
balance_bonus = (
payload.balance_bonus_kopeks
if payload.balance_bonus_kopeks is not None
else promocode.balance_bonus_kopeks
)
subscription_days = (
payload.subscription_days
if payload.subscription_days is not None
else promocode.subscription_days
)
if new_type == PromoCodeType.BALANCE and balance_bonus <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Balance bonus must be positive for balance promo codes")
if new_type in {PromoCodeType.SUBSCRIPTION_DAYS, PromoCodeType.TRIAL_SUBSCRIPTION} and subscription_days <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Subscription days must be positive for this promo code type")
valid_from = (
_normalize_datetime(payload.valid_from)
if payload.valid_from is not None
else promocode.valid_from
)
valid_until = (
_normalize_datetime(payload.valid_until)
if payload.valid_until is not None
else promocode.valid_until
)
if valid_from and valid_until and valid_from > valid_until:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "valid_from cannot be greater than valid_until")
if payload.max_uses is not None and payload.max_uses != 0 and payload.max_uses < promocode.current_uses:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "max_uses cannot be less than current uses")
@router.get("", response_model=PromoCodeListResponse)
async def list_promocodes(
_: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
is_active: Optional[bool] = Query(default=None),
) -> PromoCodeListResponse:
total = await get_promocodes_count(db, is_active=is_active) or 0
promocodes = await get_promocodes_list(db, offset=offset, limit=limit, is_active=is_active)
return PromoCodeListResponse(
items=[_serialize_promocode(promocode) for promocode in promocodes],
total=int(total),
limit=limit,
offset=offset,
)
@router.get("/{promocode_id}", response_model=PromoCodeDetailResponse)
async def get_promocode(
promocode_id: int,
_: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoCodeDetailResponse:
promocode = await db.get(PromoCode, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
stats = await get_promocode_statistics(db, promocode_id)
base = _serialize_promocode(promocode)
recent_uses = [
_serialize_recent_use(use)
for use in stats.get("recent_uses", [])
]
return PromoCodeDetailResponse(
**base.dict(),
total_uses=stats.get("total_uses", 0),
today_uses=stats.get("today_uses", 0),
recent_uses=recent_uses,
)
@router.post("", response_model=PromoCodeResponse, status_code=status.HTTP_201_CREATED)
async def create_promocode_endpoint(
payload: PromoCodeCreateRequest,
_: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoCodeResponse:
_validate_create_payload(payload)
normalized_code = payload.code.strip().upper()
normalized_valid_from = _normalize_datetime(payload.valid_from)
normalized_valid_until = _normalize_datetime(payload.valid_until)
existing = await get_promocode_by_code(db, normalized_code)
if existing:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo code with this code already exists")
creator_id = (
payload.created_by
if payload.created_by is not None and payload.created_by > 0
else None
)
promocode = await create_promocode(
db,
code=normalized_code,
type=payload.type,
balance_bonus_kopeks=payload.balance_bonus_kopeks,
subscription_days=payload.subscription_days,
max_uses=payload.max_uses,
valid_until=normalized_valid_until,
created_by=creator_id,
)
update_fields = {}
if normalized_valid_from is not None:
update_fields["valid_from"] = normalized_valid_from
if payload.is_active is not None and payload.is_active != promocode.is_active:
update_fields["is_active"] = payload.is_active
if normalized_valid_until is not None:
update_fields["valid_until"] = normalized_valid_until
if update_fields:
promocode = await update_promocode(db, promocode, **update_fields)
return _serialize_promocode(promocode)
@router.patch("/{promocode_id}", response_model=PromoCodeResponse)
async def update_promocode_endpoint(
promocode_id: int,
payload: PromoCodeUpdateRequest,
_: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PromoCodeResponse:
promocode = await db.get(PromoCode, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
_validate_update_payload(payload, promocode)
updates: dict[str, Any] = {}
if payload.code is not None:
normalized_code = payload.code.strip().upper()
if normalized_code != promocode.code:
existing = await get_promocode_by_code(db, normalized_code)
if existing and existing.id != promocode_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo code with this code already exists")
updates["code"] = normalized_code
if payload.type is not None:
updates["type"] = payload.type.value
if payload.balance_bonus_kopeks is not None:
updates["balance_bonus_kopeks"] = payload.balance_bonus_kopeks
if payload.subscription_days is not None:
updates["subscription_days"] = payload.subscription_days
if payload.max_uses is not None:
updates["max_uses"] = payload.max_uses
if payload.valid_from is not None:
updates["valid_from"] = _normalize_datetime(payload.valid_from)
if payload.valid_until is not None:
updates["valid_until"] = _normalize_datetime(payload.valid_until)
if payload.is_active is not None:
updates["is_active"] = payload.is_active
if not updates:
return _serialize_promocode(promocode)
promocode = await update_promocode(db, promocode, **updates)
return _serialize_promocode(promocode)
@router.delete(
"/{promocode_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
)
async def delete_promocode_endpoint(
promocode_id: int,
_: Any = Depends(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
promocode = await db.get(PromoCode, promocode_id)
if not promocode:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Promo code not found")
success = await delete_promocode(db, promocode)
if not success:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Failed to delete promo code")
return Response(status_code=status.HTTP_204_NO_CONTENT)
+456
View File
@@ -0,0 +1,456 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from ..dependencies import get_db_session, require_api_token
from ..schemas.remnawave import (
RemnaWaveConnectionStatus,
RemnaWaveGenericSyncResponse,
RemnaWaveInboundsResponse,
RemnaWaveNode,
RemnaWaveNodeActionRequest,
RemnaWaveNodeActionResponse,
RemnaWaveNodeListResponse,
RemnaWaveNodeStatisticsResponse,
RemnaWaveNodeUsageResponse,
RemnaWaveOperationResponse,
RemnaWaveSquad,
RemnaWaveSquadActionRequest,
RemnaWaveSquadCreateRequest,
RemnaWaveSquadListResponse,
RemnaWaveSquadUpdateRequest,
RemnaWaveStatusResponse,
RemnaWaveSystemStatsResponse,
RemnaWaveSyncFromPanelRequest,
RemnaWaveUserTrafficResponse,
)
try: # pragma: no cover - импорт может не работать без optional-зависимостей
from app.services.remnawave_service import ( # type: ignore
RemnaWaveConfigurationError,
RemnaWaveService,
)
except Exception: # pragma: no cover - при ошибке импорта скрываем функционал
RemnaWaveConfigurationError = None # type: ignore[assignment]
RemnaWaveService = None # type: ignore[assignment]
if TYPE_CHECKING: # pragma: no cover - только для типов в IDE
from app.services.remnawave_service import RemnaWaveService as RemnaWaveServiceType
else:
RemnaWaveServiceType = Any
router = APIRouter()
def _get_service() -> "RemnaWaveServiceType":
if RemnaWaveService is None: # pragma: no cover - зависимость не доступна
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="RemnaWave сервис недоступен",
)
return RemnaWaveService()
def _ensure_service_configured(service: "RemnaWaveServiceType") -> None:
if RemnaWaveService is None: # pragma: no cover - зависимость не доступна
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="RemnaWave сервис недоступен",
)
if not service.is_configured:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=service.configuration_error or "RemnaWave API не настроен",
)
def _serialize_node(node_data: Dict[str, Any]) -> RemnaWaveNode:
return RemnaWaveNode(
uuid=node_data.get("uuid", ""),
name=node_data.get("name", ""),
address=node_data.get("address", ""),
country_code=node_data.get("country_code"),
is_connected=bool(node_data.get("is_connected")),
is_disabled=bool(node_data.get("is_disabled")),
is_node_online=bool(node_data.get("is_node_online")),
is_xray_running=bool(node_data.get("is_xray_running")),
users_online=node_data.get("users_online"),
traffic_used_bytes=node_data.get("traffic_used_bytes"),
traffic_limit_bytes=node_data.get("traffic_limit_bytes"),
)
def _parse_last_updated(value: Any) -> Optional[datetime]:
if isinstance(value, datetime):
return value
if isinstance(value, str):
try:
return datetime.fromisoformat(value)
except ValueError:
return None
return None
@router.get("/status", response_model=RemnaWaveStatusResponse)
async def get_remnawave_status(
_: Any = Security(require_api_token),
) -> RemnaWaveStatusResponse:
service = _get_service()
connection_info: Optional[RemnaWaveConnectionStatus] = None
connection_result = await service.test_api_connection()
if connection_result:
connection_info = RemnaWaveConnectionStatus(**connection_result)
return RemnaWaveStatusResponse(
is_configured=service.is_configured,
configuration_error=service.configuration_error,
connection=connection_info,
)
@router.get("/system", response_model=RemnaWaveSystemStatsResponse)
async def get_system_statistics(
_: Any = Security(require_api_token),
) -> RemnaWaveSystemStatsResponse:
service = _get_service()
_ensure_service_configured(service)
stats = await service.get_system_statistics()
if not stats or "system" not in stats:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, "Не удалось получить статистику RemnaWave")
stats["last_updated"] = _parse_last_updated(stats.get("last_updated"))
return RemnaWaveSystemStatsResponse(**stats)
@router.get("/nodes", response_model=RemnaWaveNodeListResponse)
async def list_nodes(
_: Any = Security(require_api_token),
) -> RemnaWaveNodeListResponse:
service = _get_service()
_ensure_service_configured(service)
nodes = await service.get_all_nodes()
serialized = [_serialize_node(node) for node in nodes]
return RemnaWaveNodeListResponse(items=serialized, total=len(serialized))
@router.get("/nodes/realtime", response_model=List[Dict[str, Any]])
async def get_nodes_realtime_usage(
_: Any = Security(require_api_token),
) -> List[Dict[str, Any]]:
service = _get_service()
_ensure_service_configured(service)
return await service.get_nodes_realtime_usage()
@router.get("/nodes/{node_uuid}", response_model=RemnaWaveNode)
async def get_node_details(
node_uuid: str,
_: Any = Security(require_api_token),
) -> RemnaWaveNode:
service = _get_service()
_ensure_service_configured(service)
node = await service.get_node_details(node_uuid)
if not node:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Нода не найдена")
return _serialize_node(node)
@router.get("/nodes/{node_uuid}/statistics", response_model=RemnaWaveNodeStatisticsResponse)
async def get_node_statistics(
node_uuid: str,
_: Any = Security(require_api_token),
) -> RemnaWaveNodeStatisticsResponse:
service = _get_service()
_ensure_service_configured(service)
stats = await service.get_node_statistics(node_uuid)
if not stats or not stats.get("node"):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Не удалось получить информацию по ноде")
node_data = _serialize_node(stats["node"])
usage_history = stats.get("usage_history") or []
realtime = stats.get("realtime")
last_updated = _parse_last_updated(stats.get("last_updated"))
return RemnaWaveNodeStatisticsResponse(
node=node_data,
realtime=realtime,
usage_history=usage_history,
last_updated=last_updated,
)
@router.get("/nodes/{node_uuid}/usage", response_model=RemnaWaveNodeUsageResponse)
async def get_node_usage_range(
node_uuid: str,
start: Optional[datetime] = Query(default=None),
end: Optional[datetime] = Query(default=None),
_: Any = Security(require_api_token),
) -> RemnaWaveNodeUsageResponse:
service = _get_service()
_ensure_service_configured(service)
end_dt = end or datetime.utcnow()
start_dt = start or (end_dt - timedelta(days=7))
if start_dt >= end_dt:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Некорректный диапазон дат")
usage = await service.get_node_user_usage_by_range(node_uuid, start_dt, end_dt)
return RemnaWaveNodeUsageResponse(items=usage or [])
@router.post("/nodes/{node_uuid}/actions", response_model=RemnaWaveNodeActionResponse)
async def manage_node(
node_uuid: str,
payload: RemnaWaveNodeActionRequest,
_: Any = Security(require_api_token),
) -> RemnaWaveNodeActionResponse:
service = _get_service()
_ensure_service_configured(service)
success = await service.manage_node(node_uuid, payload.action)
detail = None
if success:
if payload.action == "enable":
detail = "Нода включена"
elif payload.action == "disable":
detail = "Нода отключена"
elif payload.action == "restart":
detail = "Команда перезапуска отправлена"
else:
detail = "Не удалось выполнить действие"
return RemnaWaveNodeActionResponse(success=success, detail=detail)
@router.post("/nodes/restart", response_model=RemnaWaveNodeActionResponse)
async def restart_all_nodes(
_: Any = Security(require_api_token),
) -> RemnaWaveNodeActionResponse:
service = _get_service()
_ensure_service_configured(service)
success = await service.restart_all_nodes()
detail = "Команда перезапуска отправлена" if success else "Не удалось перезапустить ноды"
return RemnaWaveNodeActionResponse(success=success, detail=detail)
@router.get("/squads", response_model=RemnaWaveSquadListResponse)
async def list_squads(
_: Any = Security(require_api_token),
) -> RemnaWaveSquadListResponse:
service = _get_service()
_ensure_service_configured(service)
squads = await service.get_all_squads()
serialized = [RemnaWaveSquad(**squad) for squad in squads]
return RemnaWaveSquadListResponse(items=serialized, total=len(serialized))
@router.get("/squads/{squad_uuid}", response_model=RemnaWaveSquad)
async def get_squad_details(
squad_uuid: str,
_: Any = Security(require_api_token),
) -> RemnaWaveSquad:
service = _get_service()
_ensure_service_configured(service)
squad = await service.get_squad_details(squad_uuid)
if not squad:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сквад не найден")
return RemnaWaveSquad(**squad)
@router.post("/squads", response_model=RemnaWaveOperationResponse, status_code=status.HTTP_201_CREATED)
async def create_squad(
payload: RemnaWaveSquadCreateRequest,
_: Any = Security(require_api_token),
) -> RemnaWaveOperationResponse:
service = _get_service()
_ensure_service_configured(service)
success = await service.create_squad(payload.name, payload.inbound_uuids)
detail = "Сквад успешно создан" if success else "Не удалось создать сквад"
return RemnaWaveOperationResponse(success=success, detail=detail)
@router.patch("/squads/{squad_uuid}", response_model=RemnaWaveOperationResponse)
async def update_squad(
squad_uuid: str,
payload: RemnaWaveSquadUpdateRequest,
_: Any = Security(require_api_token),
) -> RemnaWaveOperationResponse:
service = _get_service()
_ensure_service_configured(service)
success = False
detail = "Необходимо указать новые данные"
if payload.name is not None or payload.inbound_uuids is not None:
success = await service.update_squad(
squad_uuid,
name=payload.name,
inbounds=payload.inbound_uuids,
)
detail = "Сквад обновлен" if success else "Не удалось обновить сквад"
return RemnaWaveOperationResponse(success=success, detail=detail)
@router.post("/squads/{squad_uuid}/actions", response_model=RemnaWaveOperationResponse)
async def squad_actions(
squad_uuid: str,
payload: RemnaWaveSquadActionRequest,
_: Any = Security(require_api_token),
) -> RemnaWaveOperationResponse:
service = _get_service()
_ensure_service_configured(service)
action = payload.action
success = False
detail = "Неизвестное действие"
if action == "add_all_users":
success = await service.add_all_users_to_squad(squad_uuid)
detail = "Пользователи добавлены" if success else "Не удалось добавить пользователей"
elif action == "remove_all_users":
success = await service.remove_all_users_from_squad(squad_uuid)
detail = "Пользователи удалены" if success else "Не удалось удалить пользователей"
elif action == "delete":
success = await service.delete_squad(squad_uuid)
detail = "Сквад удален" if success else "Не удалось удалить сквад"
elif action == "rename":
if not payload.name:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо указать новое имя")
success = await service.rename_squad(squad_uuid, payload.name)
detail = "Сквад переименован" if success else "Не удалось переименовать сквад"
elif action == "update_inbounds":
if not payload.inbound_uuids:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Необходимо указать inbound_uuids")
success = await service.update_squad_inbounds(squad_uuid, payload.inbound_uuids)
detail = "Инбаунды обновлены" if success else "Не удалось обновить инбаунды"
return RemnaWaveOperationResponse(success=success, detail=detail)
@router.get("/inbounds", response_model=RemnaWaveInboundsResponse)
async def list_inbounds(
_: Any = Security(require_api_token),
) -> RemnaWaveInboundsResponse:
service = _get_service()
_ensure_service_configured(service)
inbounds = await service.get_all_inbounds()
return RemnaWaveInboundsResponse(items=inbounds or [])
@router.get("/users/{telegram_id}/traffic", response_model=RemnaWaveUserTrafficResponse)
async def get_user_traffic(
telegram_id: int,
_: Any = Security(require_api_token),
) -> RemnaWaveUserTrafficResponse:
service = _get_service()
_ensure_service_configured(service)
stats = await service.get_user_traffic_stats(telegram_id)
if not stats:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Пользователь не найден в RemnaWave")
return RemnaWaveUserTrafficResponse(telegram_id=telegram_id, **stats)
@router.post("/sync/from-panel", response_model=RemnaWaveGenericSyncResponse)
async def sync_from_panel(
payload: RemnaWaveSyncFromPanelRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> RemnaWaveGenericSyncResponse:
service = _get_service()
_ensure_service_configured(service)
try:
stats = await service.sync_users_from_panel(db, payload.mode)
detail = "Синхронизация из панели выполнена"
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
except Exception as exc: # pragma: no cover - точный тип зависит от импорта
if RemnaWaveConfigurationError and isinstance(exc, RemnaWaveConfigurationError):
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(exc)) from exc
raise
@router.post("/sync/to-panel", response_model=RemnaWaveGenericSyncResponse)
async def sync_to_panel(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> RemnaWaveGenericSyncResponse:
service = _get_service()
_ensure_service_configured(service)
stats = await service.sync_users_to_panel(db)
detail = "Синхронизация в панель выполнена"
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
@router.post("/sync/subscriptions/validate", response_model=RemnaWaveGenericSyncResponse)
async def validate_and_fix_subscriptions(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> RemnaWaveGenericSyncResponse:
service = _get_service()
_ensure_service_configured(service)
stats = await service.validate_and_fix_subscriptions(db)
detail = "Подписки проверены"
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
@router.post("/sync/subscriptions/cleanup", response_model=RemnaWaveGenericSyncResponse)
async def cleanup_orphaned_subscriptions(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> RemnaWaveGenericSyncResponse:
service = _get_service()
_ensure_service_configured(service)
stats = await service.cleanup_orphaned_subscriptions(db)
detail = "Очистка завершена"
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
@router.post("/sync/subscriptions/statuses", response_model=RemnaWaveGenericSyncResponse)
async def sync_subscription_statuses(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> RemnaWaveGenericSyncResponse:
service = _get_service()
_ensure_service_configured(service)
stats = await service.sync_subscription_statuses(db)
detail = "Статусы подписок синхронизированы"
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=stats)
@router.get("/sync/recommendations", response_model=RemnaWaveGenericSyncResponse)
async def get_sync_recommendations(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> RemnaWaveGenericSyncResponse:
service = _get_service()
_ensure_service_configured(service)
data = await service.get_sync_recommendations(db)
detail = "Рекомендации получены"
return RemnaWaveGenericSyncResponse(success=True, detail=detail, data=data)
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, Security
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
Subscription,
SubscriptionStatus,
Ticket,
TicketStatus,
Transaction,
TransactionType,
User,
UserStatus,
)
from ..dependencies import get_db_session, require_api_token
router = APIRouter()
@router.get(
"/overview",
summary="Общая статистика",
response_description="Агрегированные показатели пользователей, подписок, саппорта и платежей",
responses={
200: {
"content": {
"application/json": {
"example": {
"users": {
"total": 12345,
"active": 9876,
"blocked": 321,
"balance_kopeks": 1234567,
"balance_rubles": 12345.67,
},
"subscriptions": {
"active": 4321,
"expired": 210,
},
"support": {
"open_tickets": 42,
},
"payments": {
"today_kopeks": 654321,
"today_rubles": 6543.21,
},
}
}
}
}
},
)
async def stats_overview(
_: object = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> dict[str, object]:
total_users = await db.scalar(select(func.count()).select_from(User)) or 0
active_users = await db.scalar(
select(func.count()).select_from(User).where(User.status == UserStatus.ACTIVE.value)
) or 0
blocked_users = await db.scalar(
select(func.count()).select_from(User).where(User.status == UserStatus.BLOCKED.value)
) or 0
total_balance_kopeks = await db.scalar(
select(func.coalesce(func.sum(User.balance_kopeks), 0))
) or 0
active_subscriptions = await db.scalar(
select(func.count()).select_from(Subscription).where(
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
) or 0
expired_subscriptions = await db.scalar(
select(func.count()).select_from(Subscription).where(
Subscription.status == SubscriptionStatus.EXPIRED.value,
)
) or 0
pending_tickets = await db.scalar(
select(func.count()).select_from(Ticket).where(
Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])
)
) or 0
today = datetime.utcnow().date()
today_transactions = await db.scalar(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)).where(
func.date(Transaction.created_at) == today,
Transaction.type == TransactionType.DEPOSIT.value,
)
) or 0
return {
"users": {
"total": total_users,
"active": active_users,
"blocked": blocked_users,
"balance_kopeks": int(total_balance_kopeks),
"balance_rubles": round(total_balance_kopeks / 100, 2),
},
"subscriptions": {
"active": active_subscriptions,
"expired": expired_subscriptions,
},
"support": {
"open_tickets": pending_tickets,
},
"payments": {
"today_kopeks": int(today_transactions),
"today_rubles": round(today_transactions / 100, 2),
},
}
+205
View File
@@ -0,0 +1,205 @@
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.crud.subscription import (
add_subscription_devices,
add_subscription_squad,
add_subscription_traffic,
create_paid_subscription,
create_trial_subscription,
extend_subscription,
get_subscription_by_user_id,
remove_subscription_squad,
)
from app.database.models import Subscription, SubscriptionStatus
from ..dependencies import get_db_session, require_api_token
from ..schemas.subscriptions import (
SubscriptionCreateRequest,
SubscriptionDevicesRequest,
SubscriptionExtendRequest,
SubscriptionResponse,
SubscriptionSquadRequest,
SubscriptionTrafficRequest,
)
router = APIRouter()
def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
return SubscriptionResponse(
id=subscription.id,
user_id=subscription.user_id,
status=subscription.status,
actual_status=subscription.actual_status,
is_trial=subscription.is_trial,
start_date=subscription.start_date,
end_date=subscription.end_date,
traffic_limit_gb=subscription.traffic_limit_gb,
traffic_used_gb=subscription.traffic_used_gb,
device_limit=subscription.device_limit,
autopay_enabled=subscription.autopay_enabled,
autopay_days_before=subscription.autopay_days_before,
subscription_url=subscription.subscription_url,
subscription_crypto_link=subscription.subscription_crypto_link,
connected_squads=list(subscription.connected_squads or []),
created_at=subscription.created_at,
updated_at=subscription.updated_at,
)
async def _get_subscription(db: AsyncSession, subscription_id: int) -> Subscription:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(Subscription.id == subscription_id)
)
subscription = result.scalar_one_or_none()
if not subscription:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Subscription not found")
return subscription
@router.get("", response_model=list[SubscriptionResponse])
async def list_subscriptions(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status_filter: Optional[SubscriptionStatus] = Query(default=None, alias="status"),
user_id: Optional[int] = Query(default=None),
is_trial: Optional[bool] = Query(default=None),
) -> list[SubscriptionResponse]:
query = select(Subscription).options(selectinload(Subscription.user))
if status_filter:
query = query.where(Subscription.status == status_filter.value)
if user_id:
query = query.where(Subscription.user_id == user_id)
if is_trial is not None:
query = query.where(Subscription.is_trial.is_(is_trial))
query = query.order_by(Subscription.created_at.desc()).offset(offset).limit(limit)
result = await db.execute(query)
subscriptions = result.scalars().all()
return [_serialize_subscription(sub) for sub in subscriptions]
@router.get("/{subscription_id}", response_model=SubscriptionResponse)
async def get_subscription(
subscription_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SubscriptionResponse:
subscription = await _get_subscription(db, subscription_id)
return _serialize_subscription(subscription)
@router.post("", response_model=SubscriptionResponse, status_code=status.HTTP_201_CREATED)
async def create_subscription(
payload: SubscriptionCreateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SubscriptionResponse:
existing = await get_subscription_by_user_id(db, payload.user_id)
if existing:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User already has a subscription")
if payload.is_trial:
subscription = await create_trial_subscription(
db,
user_id=payload.user_id,
duration_days=payload.duration_days,
traffic_limit_gb=payload.traffic_limit_gb,
device_limit=payload.device_limit,
squad_uuid=payload.squad_uuid,
)
else:
if payload.duration_days is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "duration_days is required for paid subscriptions")
subscription = await create_paid_subscription(
db,
user_id=payload.user_id,
duration_days=payload.duration_days,
traffic_limit_gb=payload.traffic_limit_gb or settings.DEFAULT_TRAFFIC_LIMIT_GB,
device_limit=payload.device_limit or settings.DEFAULT_DEVICE_LIMIT,
connected_squads=payload.connected_squads or [],
)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
@router.post("/{subscription_id}/extend", response_model=SubscriptionResponse)
async def extend_subscription_endpoint(
subscription_id: int,
payload: SubscriptionExtendRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SubscriptionResponse:
subscription = await _get_subscription(db, subscription_id)
subscription = await extend_subscription(db, subscription, payload.days)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
@router.post("/{subscription_id}/traffic", response_model=SubscriptionResponse)
async def add_subscription_traffic_endpoint(
subscription_id: int,
payload: SubscriptionTrafficRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SubscriptionResponse:
subscription = await _get_subscription(db, subscription_id)
subscription = await add_subscription_traffic(db, subscription, payload.gb)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
@router.post("/{subscription_id}/devices", response_model=SubscriptionResponse)
async def add_subscription_devices_endpoint(
subscription_id: int,
payload: SubscriptionDevicesRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SubscriptionResponse:
subscription = await _get_subscription(db, subscription_id)
subscription = await add_subscription_devices(db, subscription, payload.devices)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
@router.post("/{subscription_id}/squads", response_model=SubscriptionResponse)
async def add_subscription_squad_endpoint(
subscription_id: int,
payload: SubscriptionSquadRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SubscriptionResponse:
if not payload.squad_uuid:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "squad_uuid is required")
subscription = await _get_subscription(db, subscription_id)
subscription = await add_subscription_squad(db, subscription, payload.squad_uuid)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
@router.delete("/{subscription_id}/squads/{squad_uuid}", response_model=SubscriptionResponse)
async def remove_subscription_squad_endpoint(
subscription_id: int,
squad_uuid: str,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SubscriptionResponse:
subscription = await _get_subscription(db, subscription_id)
subscription = await remove_subscription_squad(db, subscription, squad_uuid)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.ticket import TicketCRUD
from app.database.models import Ticket, TicketMessage, TicketStatus
from ..dependencies import get_db_session, require_api_token
from ..schemas.tickets import (
TicketMessageResponse,
TicketPriorityUpdateRequest,
TicketReplyBlockRequest,
TicketResponse,
TicketStatusUpdateRequest,
)
router = APIRouter()
def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
return TicketMessageResponse(
id=message.id,
user_id=message.user_id,
message_text=message.message_text,
is_from_admin=message.is_from_admin,
has_media=message.has_media,
media_type=message.media_type,
media_caption=message.media_caption,
created_at=message.created_at,
)
def _serialize_ticket(ticket: Ticket, include_messages: bool = False) -> TicketResponse:
messages = []
if include_messages:
messages = sorted(ticket.messages, key=lambda m: m.created_at)
return TicketResponse(
id=ticket.id,
user_id=ticket.user_id,
title=ticket.title,
status=ticket.status,
priority=ticket.priority,
created_at=ticket.created_at,
updated_at=ticket.updated_at,
closed_at=ticket.closed_at,
user_reply_block_permanent=ticket.user_reply_block_permanent,
user_reply_block_until=ticket.user_reply_block_until,
messages=[_serialize_message(message) for message in messages],
)
@router.get("", response_model=list[TicketResponse])
async def list_tickets(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status_filter: Optional[TicketStatus] = Query(default=None, alias="status"),
priority: Optional[str] = Query(default=None),
user_id: Optional[int] = Query(default=None),
) -> list[TicketResponse]:
status_value = status_filter.value if status_filter else None
if user_id:
tickets = await TicketCRUD.get_user_tickets(
db,
user_id=user_id,
status=status_value,
limit=limit,
offset=offset,
)
else:
tickets = await TicketCRUD.get_all_tickets(
db,
status=status_value,
priority=priority,
limit=limit,
offset=offset,
)
return [_serialize_ticket(ticket) for ticket in tickets]
@router.get("/{ticket_id}", response_model=TicketResponse)
async def get_ticket(
ticket_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketResponse:
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
if not ticket:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
return _serialize_ticket(ticket, include_messages=True)
@router.post("/{ticket_id}/status", response_model=TicketResponse)
async def update_ticket_status(
ticket_id: int,
payload: TicketStatusUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketResponse:
try:
status_value = TicketStatus(payload.status).value
except ValueError as error:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid ticket status") from error
closed_at = datetime.utcnow() if status_value == TicketStatus.CLOSED.value else None
success = await TicketCRUD.update_ticket_status(db, ticket_id, status_value, closed_at)
if not success:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
return _serialize_ticket(ticket, include_messages=True)
@router.post("/{ticket_id}/priority", response_model=TicketResponse)
async def update_ticket_priority(
ticket_id: int,
payload: TicketPriorityUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketResponse:
allowed_priorities = {"low", "normal", "high", "urgent"}
if payload.priority not in allowed_priorities:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid priority")
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
if not ticket:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
ticket.priority = payload.priority
ticket.updated_at = datetime.utcnow()
await db.commit()
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
return _serialize_ticket(ticket, include_messages=True)
@router.post("/{ticket_id}/reply-block", response_model=TicketResponse)
async def update_reply_block(
ticket_id: int,
payload: TicketReplyBlockRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketResponse:
until = payload.until
if not payload.permanent and until and until <= datetime.utcnow():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Block expiration must be in the future")
success = await TicketCRUD.set_user_reply_block(
db,
ticket_id,
permanent=payload.permanent,
until=until,
)
if not success:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
return _serialize_ticket(ticket, include_messages=True)
@router.delete("/{ticket_id}/reply-block", response_model=TicketResponse)
async def clear_reply_block(
ticket_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TicketResponse:
success = await TicketCRUD.set_user_reply_block(
db,
ticket_id,
permanent=False,
until=None,
)
if not success:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Ticket not found")
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=False)
return _serialize_ticket(ticket, include_messages=True)
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Response, Security, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.web_api_token import (
delete_token,
get_token_by_id,
list_tokens,
)
from app.database.models import WebApiToken
from app.services.web_api_token_service import web_api_token_service
from ..dependencies import get_db_session, require_api_token
from ..schemas.tokens import TokenCreateRequest, TokenCreateResponse, TokenResponse
router = APIRouter()
def _serialize(token: WebApiToken) -> TokenResponse:
return TokenResponse(
id=token.id,
name=token.name,
prefix=token.token_prefix,
description=token.description,
is_active=token.is_active,
created_at=token.created_at,
updated_at=token.updated_at,
expires_at=token.expires_at,
last_used_at=token.last_used_at,
last_used_ip=token.last_used_ip,
created_by=token.created_by,
)
@router.get("", response_model=list[TokenResponse])
async def get_tokens(
_: WebApiToken = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> list[TokenResponse]:
tokens = await list_tokens(db, include_inactive=True)
return [_serialize(token) for token in tokens]
@router.post("", response_model=TokenCreateResponse, status_code=status.HTTP_201_CREATED)
async def create_token(
payload: TokenCreateRequest,
actor: WebApiToken = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TokenCreateResponse:
token_value, token = await web_api_token_service.create_token(
db,
name=payload.name.strip(),
description=payload.description,
expires_at=payload.expires_at,
created_by=actor.name,
)
await db.commit()
base = _serialize(token).model_dump()
base["token"] = token_value
return TokenCreateResponse(**base)
@router.post("/{token_id}/revoke", response_model=TokenResponse)
async def revoke_token(
token_id: int,
_: WebApiToken = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TokenResponse:
token = await get_token_by_id(db, token_id)
if not token:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
await web_api_token_service.revoke_token(db, token)
await db.commit()
return _serialize(token)
@router.post("/{token_id}/activate", response_model=TokenResponse)
async def activate_token(
token_id: int,
_: WebApiToken = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TokenResponse:
token = await get_token_by_id(db, token_id)
if not token:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
await web_api_token_service.activate_token(db, token)
await db.commit()
return _serialize(token)
@router.delete("/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_token_endpoint(
token_id: int,
_: WebApiToken = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> Response:
token = await get_token_by_id(db, token_id)
if not token:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Token not found")
await delete_token(db, token)
await db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
from fastapi import APIRouter, Depends, Query, Security
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Transaction
from ..dependencies import get_db_session, require_api_token
from ..schemas.transactions import TransactionListResponse, TransactionResponse
router = APIRouter()
def _serialize(transaction: Transaction) -> TransactionResponse:
return TransactionResponse(
id=transaction.id,
user_id=transaction.user_id,
type=transaction.type,
amount_kopeks=transaction.amount_kopeks,
amount_rubles=round(transaction.amount_kopeks / 100, 2),
description=transaction.description,
payment_method=transaction.payment_method,
external_id=transaction.external_id,
is_completed=transaction.is_completed,
created_at=transaction.created_at,
completed_at=transaction.completed_at,
)
@router.get("", response_model=TransactionListResponse)
async def list_transactions(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
user_id: Optional[int] = Query(default=None),
type_filter: Optional[str] = Query(default=None, alias="type"),
payment_method: Optional[str] = Query(default=None),
is_completed: Optional[bool] = Query(default=None),
date_from: Optional[datetime] = Query(default=None),
date_to: Optional[datetime] = Query(default=None),
) -> TransactionListResponse:
base_query = select(Transaction)
conditions = []
if user_id:
conditions.append(Transaction.user_id == user_id)
if type_filter:
conditions.append(Transaction.type == type_filter)
if payment_method:
conditions.append(Transaction.payment_method == payment_method)
if is_completed is not None:
conditions.append(Transaction.is_completed.is_(is_completed))
if date_from:
conditions.append(Transaction.created_at >= date_from)
if date_to:
conditions.append(Transaction.created_at <= date_to)
if conditions:
base_query = base_query.where(and_(*conditions))
total_query = base_query.with_only_columns(func.count()).order_by(None)
total = await db.scalar(total_query) or 0
result = await db.execute(
base_query.order_by(Transaction.created_at.desc()).offset(offset).limit(limit)
)
transactions = result.scalars().all()
return TransactionListResponse(
items=[_serialize(tx) for tx in transactions],
total=int(total),
limit=limit,
offset=offset,
)
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.user import (
add_user_balance,
create_user,
get_user_by_id,
get_user_by_referral_code,
get_user_by_telegram_id,
update_user,
)
from app.database.models import PromoGroup, Subscription, User, UserStatus
from ..dependencies import get_db_session, require_api_token
from ..schemas.users import (
BalanceUpdateRequest,
PromoGroupSummary,
SubscriptionSummary,
UserCreateRequest,
UserListResponse,
UserResponse,
UserUpdateRequest,
)
router = APIRouter()
def _serialize_promo_group(group: Optional[PromoGroup]) -> Optional[PromoGroupSummary]:
if not group:
return None
return PromoGroupSummary(
id=group.id,
name=group.name,
server_discount_percent=group.server_discount_percent,
traffic_discount_percent=group.traffic_discount_percent,
device_discount_percent=group.device_discount_percent,
apply_discounts_to_addons=getattr(group, "apply_discounts_to_addons", True),
)
def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[SubscriptionSummary]:
if not subscription:
return None
return SubscriptionSummary(
id=subscription.id,
status=subscription.status,
actual_status=subscription.actual_status,
is_trial=subscription.is_trial,
start_date=subscription.start_date,
end_date=subscription.end_date,
traffic_limit_gb=subscription.traffic_limit_gb,
traffic_used_gb=subscription.traffic_used_gb,
device_limit=subscription.device_limit,
autopay_enabled=subscription.autopay_enabled,
autopay_days_before=subscription.autopay_days_before,
subscription_url=subscription.subscription_url,
subscription_crypto_link=subscription.subscription_crypto_link,
connected_squads=list(subscription.connected_squads or []),
)
def _serialize_user(user: User) -> UserResponse:
subscription = getattr(user, "subscription", None)
promo_group = getattr(user, "promo_group", None)
return UserResponse(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
status=user.status,
language=user.language,
balance_kopeks=user.balance_kopeks,
balance_rubles=round(user.balance_kopeks / 100, 2),
referral_code=user.referral_code,
referred_by_id=user.referred_by_id,
has_had_paid_subscription=user.has_had_paid_subscription,
has_made_first_topup=user.has_made_first_topup,
created_at=user.created_at,
updated_at=user.updated_at,
last_activity=user.last_activity,
promo_group=_serialize_promo_group(promo_group),
subscription=_serialize_subscription(subscription),
)
def _apply_search_filter(query, search: str):
search_lower = f"%{search.lower()}%"
conditions = [
func.lower(User.username).like(search_lower),
func.lower(User.first_name).like(search_lower),
func.lower(User.last_name).like(search_lower),
func.lower(User.referral_code).like(search_lower),
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
conditions.append(User.id == int(search))
return query.where(or_(*conditions))
@router.get("", response_model=UserListResponse)
async def list_users(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
status_filter: Optional[UserStatus] = Query(default=None, alias="status"),
promo_group_id: Optional[int] = Query(default=None),
search: Optional[str] = Query(default=None),
) -> UserListResponse:
base_query = (
select(User)
.options(
selectinload(User.subscription),
selectinload(User.promo_group),
)
)
if status_filter:
base_query = base_query.where(User.status == status_filter.value)
if promo_group_id:
base_query = base_query.where(User.promo_group_id == promo_group_id)
if search:
base_query = _apply_search_filter(base_query, search)
total_query = base_query.with_only_columns(func.count()).order_by(None)
total = await db.scalar(total_query) or 0
result = await db.execute(
base_query.order_by(User.created_at.desc()).offset(offset).limit(limit)
)
users = result.scalars().unique().all()
return UserListResponse(
items=[_serialize_user(user) for user in users],
total=int(total),
limit=limit,
offset=offset,
)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
return _serialize_user(user)
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user_endpoint(
payload: UserCreateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
existing = await get_user_by_telegram_id(db, payload.telegram_id)
if existing:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "User with this telegram_id already exists")
user = await create_user(
db,
telegram_id=payload.telegram_id,
username=payload.username,
first_name=payload.first_name,
last_name=payload.last_name,
language=payload.language,
referred_by_id=payload.referred_by_id,
)
if payload.promo_group_id and payload.promo_group_id != user.promo_group_id:
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
if not promo_group:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
user = await update_user(db, user, promo_group_id=promo_group.id)
user = await get_user_by_id(db, user.id)
return _serialize_user(user)
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user_endpoint(
user_id: int,
payload: UserUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
updates: dict[str, Any] = {}
if payload.username is not None:
updates["username"] = payload.username
if payload.first_name is not None:
updates["first_name"] = payload.first_name
if payload.last_name is not None:
updates["last_name"] = payload.last_name
if payload.language is not None:
updates["language"] = payload.language
if payload.has_had_paid_subscription is not None:
updates["has_had_paid_subscription"] = payload.has_had_paid_subscription
if payload.has_made_first_topup is not None:
updates["has_made_first_topup"] = payload.has_made_first_topup
if payload.status is not None:
try:
status_value = UserStatus(payload.status).value
except ValueError as error:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid status") from error
updates["status"] = status_value
if payload.promo_group_id is not None:
promo_group = await get_promo_group_by_id(db, payload.promo_group_id)
if not promo_group:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Promo group not found")
updates["promo_group_id"] = promo_group.id
if payload.referral_code is not None and payload.referral_code != user.referral_code:
existing_code_owner = await get_user_by_referral_code(db, payload.referral_code)
if existing_code_owner and existing_code_owner.id != user.id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Referral code already in use")
updates["referral_code"] = payload.referral_code
if not updates:
return _serialize_user(user)
user = await update_user(db, user, **updates)
user = await get_user_by_id(db, user.id)
return _serialize_user(user)
@router.post("/{user_id}/balance", response_model=UserResponse)
async def update_balance(
user_id: int,
payload: BalanceUpdateRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> UserResponse:
if payload.amount_kopeks == 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Amount must be non-zero")
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "User not found")
success = await add_user_balance(
db,
user,
amount_kopeks=payload.amount_kopeks,
description=payload.description or "Корректировка через веб-API",
create_transaction=payload.create_transaction,
)
if not success:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to update balance")
user = await get_user_by_id(db, user_id)
return _serialize_user(user)
View File
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class BackupCreateResponse(BaseModel):
task_id: str
status: str = Field(..., description="Текущий статус задачи")
class BackupInfo(BaseModel):
filename: str
filepath: str
timestamp: Optional[datetime] = None
tables_count: Optional[int] = None
total_records: Optional[int] = None
compressed: bool
file_size_bytes: int
file_size_mb: float
created_by: Optional[int] = None
database_type: Optional[str] = None
version: Optional[str] = None
error: Optional[str] = None
class BackupListResponse(BaseModel):
items: list[BackupInfo]
total: int
limit: int
offset: int
class BackupStatusResponse(BaseModel):
task_id: str
status: str
message: Optional[str] = None
file_path: Optional[str] = Field(
default=None,
description="Полный путь до созданного бекапа, если задача завершена",
)
created_by: Optional[int] = None
created_at: datetime
updated_at: datetime
class BackupTaskInfo(BackupStatusResponse):
pass
class BackupTaskListResponse(BaseModel):
items: list[BackupTaskInfo]
total: int
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
from datetime import datetime
from typing import ClassVar, Optional
from pydantic import BaseModel, Field, validator
from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS
class BroadcastMedia(BaseModel):
type: str = Field(pattern=r"^(photo|video|document)$")
file_id: str
caption: Optional[str] = None
class BroadcastCreateRequest(BaseModel):
target: str
message_text: str = Field(..., min_length=1, max_length=4000)
selected_buttons: list[str] = Field(
default_factory=lambda: list(DEFAULT_BROADCAST_BUTTONS)
)
media: Optional[BroadcastMedia] = None
_ALLOWED_TARGETS: ClassVar[set[str]] = {
"all",
"active",
"trial",
"no",
"expiring",
"expired",
"active_zero",
"trial_zero",
"zero",
}
_CUSTOM_TARGETS: ClassVar[set[str]] = {
"today",
"week",
"month",
"active_today",
"inactive_week",
"inactive_month",
"referrals",
"direct",
}
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
"no_sub": "no",
}
@validator("target")
def validate_target(cls, value: str) -> str:
normalized = value.strip().lower()
normalized = cls._TARGET_ALIASES.get(normalized, normalized)
if normalized in cls._ALLOWED_TARGETS:
return normalized
if normalized.startswith("custom_"):
criteria = normalized[len("custom_"):]
if criteria in cls._CUSTOM_TARGETS:
return normalized
raise ValueError("Unsupported target value")
@validator("selected_buttons", pre=True)
def validate_selected_buttons(cls, value):
if value is None:
return []
if not isinstance(value, (list, tuple)):
raise TypeError("selected_buttons must be an array")
seen = set()
ordered: list[str] = []
for raw_button in value:
button = str(raw_button).strip()
if not button:
continue
if button not in BROADCAST_BUTTONS:
raise ValueError(f"Unsupported button '{button}'")
if button in seen:
continue
ordered.append(button)
seen.add(button)
return ordered
class BroadcastResponse(BaseModel):
id: int
target_type: str
message_text: str
has_media: bool
media_type: Optional[str] = None
media_file_id: Optional[str] = None
media_caption: Optional[str] = None
total_count: int
sent_count: int
failed_count: int
status: str
admin_id: Optional[int] = None
admin_name: Optional[str] = None
created_at: datetime
completed_at: Optional[datetime] = None
class BroadcastListResponse(BaseModel):
items: list[BroadcastResponse]
total: int
limit: int
offset: int
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Literal, Optional
from pydantic import BaseModel, Field, validator
CampaignBonusType = Annotated[Literal["balance", "subscription"], Field(description="Тип бонуса кампании")]
class CampaignBase(BaseModel):
name: str = Field(..., max_length=255)
start_parameter: str = Field(..., max_length=64, description="Start parameter для deep-link (уникальный)")
bonus_type: CampaignBonusType
balance_bonus_kopeks: int = Field(0, ge=0)
subscription_duration_days: Optional[int] = Field(None, ge=0)
subscription_traffic_gb: Optional[int] = Field(None, ge=0)
subscription_device_limit: Optional[int] = Field(None, ge=0)
subscription_squads: list[str] = Field(default_factory=list)
@validator("name", "start_parameter")
def strip_strings(cls, value: str) -> str: # noqa: D401,B902
return value.strip()
class CampaignCreateRequest(CampaignBase):
is_active: bool = True
@validator("balance_bonus_kopeks")
def validate_balance_bonus(cls, value: int, values: dict) -> int: # noqa: D401,B902
if values.get("bonus_type") == "balance" and value <= 0:
raise ValueError("balance_bonus_kopeks must be positive for balance bonus")
return value
@validator("subscription_duration_days")
def validate_subscription_bonus(cls, value: Optional[int], values: dict): # noqa: D401,B902
if values.get("bonus_type") == "subscription":
if value is None or value <= 0:
raise ValueError("subscription_duration_days must be positive for subscription bonus")
return value
class CampaignResponse(BaseModel):
id: int
name: str
start_parameter: str
bonus_type: CampaignBonusType
balance_bonus_kopeks: int
balance_bonus_rubles: float
subscription_duration_days: Optional[int] = None
subscription_traffic_gb: Optional[int] = None
subscription_device_limit: Optional[int] = None
subscription_squads: list[str] = Field(default_factory=list)
is_active: bool
created_by: Optional[int] = None
created_at: datetime
updated_at: datetime
registrations_count: int = 0
class CampaignListResponse(BaseModel):
items: list[CampaignResponse]
total: int
limit: int
offset: int
class CampaignUpdateRequest(BaseModel):
name: Optional[str] = Field(None, max_length=255)
start_parameter: Optional[str] = Field(None, max_length=64)
bonus_type: Optional[CampaignBonusType] = None
balance_bonus_kopeks: Optional[int] = Field(None, ge=0)
subscription_duration_days: Optional[int] = Field(None, ge=0)
subscription_traffic_gb: Optional[int] = Field(None, ge=0)
subscription_device_limit: Optional[int] = Field(None, ge=0)
subscription_squads: Optional[list[str]] = None
is_active: Optional[bool] = None
@validator("name", "start_parameter", pre=True)
def strip_optional_strings(cls, value: Optional[str]): # noqa: D401,B902
if isinstance(value, str):
return value.strip()
return value
@validator("balance_bonus_kopeks")
def validate_balance_bonus(cls, value: Optional[int], values: dict): # noqa: D401,B902
bonus_type = values.get("bonus_type")
if bonus_type == "balance" and value is not None and value <= 0:
raise ValueError("balance_bonus_kopeks must be positive for balance bonus")
return value
@validator("subscription_duration_days")
def validate_subscription_bonus(cls, value: Optional[int], values: dict): # noqa: D401,B902
bonus_type = values.get("bonus_type")
if bonus_type == "subscription" and value is not None and value <= 0:
raise ValueError("subscription_duration_days must be positive for subscription bonus")
return value
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
class SettingCategorySummary(BaseModel):
"""Краткое описание категории настройки."""
key: str
label: str
items: int
model_config = ConfigDict(extra="forbid")
class SettingCategoryRef(BaseModel):
"""Ссылка на категорию, к которой относится настройка."""
key: str
label: str
model_config = ConfigDict(extra="forbid")
class SettingChoice(BaseModel):
"""Вариант значения для настройки с выбором."""
value: Any
label: str
description: Optional[str] = None
model_config = ConfigDict(extra="forbid")
class SettingDefinition(BaseModel):
"""Полное описание настройки и её текущего состояния."""
key: str
name: str
category: SettingCategoryRef
type: str
is_optional: bool
current: Any | None = Field(default=None)
original: Any | None = Field(default=None)
has_override: bool
choices: list[SettingChoice] = Field(default_factory=list)
model_config = ConfigDict(extra="forbid")
class SettingUpdateRequest(BaseModel):
"""Запрос на обновление значения настройки."""
value: Any
model_config = ConfigDict(extra="forbid")
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict
class HealthFeatureFlags(BaseModel):
"""Флаги доступности функций административного API."""
monitoring: bool
maintenance: bool
reporting: bool
webhooks: bool
model_config = ConfigDict(extra="forbid")
class HealthCheckResponse(BaseModel):
"""Ответ на health-check административного API."""
status: str
api_version: str
bot_version: str | None
features: HealthFeatureFlags
model_config = ConfigDict(extra="forbid")
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime
from typing import Dict, Optional
from pydantic import BaseModel, Field, validator
def _normalize_period_discounts(value: Optional[Dict[object, object]]) -> Optional[Dict[int, int]]:
if value is None:
return None
normalized: Dict[int, int] = {}
if isinstance(value, dict):
for raw_key, raw_value in value.items():
try:
key = int(raw_key)
normalized[key] = int(raw_value)
except (TypeError, ValueError):
continue
return normalized or None
class PromoGroupResponse(BaseModel):
id: int
name: str
server_discount_percent: int
traffic_discount_percent: int
device_discount_percent: int
period_discounts: Dict[int, int] = Field(default_factory=dict)
auto_assign_total_spent_kopeks: Optional[int] = None
apply_discounts_to_addons: bool
is_default: bool
members_count: int = 0
created_at: datetime
updated_at: datetime
class _PromoGroupBase(BaseModel):
period_discounts: Optional[Dict[int, int]] = Field(
default=None,
description=(
"Словарь скидок по длительности подписки. Ключ — количество месяцев, "
"значение — скидка в процентах. Например: {1: 10, 6: 20}."
),
example={1: 10, 6: 20},
)
@validator("period_discounts", pre=True)
def validate_period_discounts(cls, value): # noqa: D401,B902
return _normalize_period_discounts(value)
class PromoGroupCreateRequest(_PromoGroupBase):
name: str
server_discount_percent: int = 0
traffic_discount_percent: int = 0
device_discount_percent: int = 0
auto_assign_total_spent_kopeks: Optional[int] = None
apply_discounts_to_addons: bool = True
is_default: bool = False
class PromoGroupUpdateRequest(_PromoGroupBase):
name: Optional[str] = None
server_discount_percent: Optional[int] = None
traffic_discount_percent: Optional[int] = None
device_discount_percent: Optional[int] = None
auto_assign_total_spent_kopeks: Optional[int] = None
apply_discounts_to_addons: Optional[bool] = None
is_default: Optional[bool] = None
class PromoGroupListResponse(BaseModel):
items: list[PromoGroupResponse]
total: int
limit: int
offset: int
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
from app.database.models import PromoCodeType
class PromoCodeResponse(BaseModel):
id: int
code: str
type: PromoCodeType
balance_bonus_kopeks: int
balance_bonus_rubles: float
subscription_days: int
max_uses: int
current_uses: int
uses_left: int
is_active: bool
is_valid: bool
valid_from: datetime
valid_until: Optional[datetime] = None
created_by: Optional[int] = None
created_at: datetime
updated_at: datetime
class PromoCodeListResponse(BaseModel):
items: list[PromoCodeResponse]
total: int
limit: int
offset: int
class PromoCodeCreateRequest(BaseModel):
code: str
type: PromoCodeType
balance_bonus_kopeks: int = 0
subscription_days: int = 0
max_uses: int = Field(default=1, ge=0)
valid_from: Optional[datetime] = None
valid_until: Optional[datetime] = None
is_active: bool = True
created_by: Optional[int] = None
class PromoCodeUpdateRequest(BaseModel):
code: Optional[str] = None
type: Optional[PromoCodeType] = None
balance_bonus_kopeks: Optional[int] = None
subscription_days: Optional[int] = None
max_uses: Optional[int] = Field(default=None, ge=0)
valid_from: Optional[datetime] = None
valid_until: Optional[datetime] = None
is_active: Optional[bool] = None
class PromoCodeRecentUse(BaseModel):
id: int
user_id: int
user_username: Optional[str] = None
user_full_name: Optional[str] = None
user_telegram_id: Optional[int] = None
used_at: datetime
class PromoCodeDetailResponse(PromoCodeResponse):
total_uses: int
today_uses: int
recent_uses: list[PromoCodeRecentUse] = Field(default_factory=list)
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
class RemnaWaveConnectionStatus(BaseModel):
status: str
message: str
api_url: Optional[str] = None
status_code: Optional[int] = None
system_info: Optional[Dict[str, Any]] = None
class RemnaWaveStatusResponse(BaseModel):
is_configured: bool
configuration_error: Optional[str] = None
connection: Optional[RemnaWaveConnectionStatus] = None
class RemnaWaveNode(BaseModel):
uuid: str
name: str
address: str
country_code: Optional[str] = None
is_connected: bool
is_disabled: bool
is_node_online: bool
is_xray_running: bool
users_online: Optional[int] = None
traffic_used_bytes: Optional[int] = None
traffic_limit_bytes: Optional[int] = None
class RemnaWaveNodeListResponse(BaseModel):
items: List[RemnaWaveNode]
total: int
class RemnaWaveNodeActionRequest(BaseModel):
action: Literal["enable", "disable", "restart"]
class RemnaWaveNodeActionResponse(BaseModel):
success: bool
detail: Optional[str] = None
class RemnaWaveNodeStatisticsResponse(BaseModel):
node: RemnaWaveNode
realtime: Optional[Dict[str, Any]] = None
usage_history: List[Dict[str, Any]] = Field(default_factory=list)
last_updated: Optional[datetime] = None
class RemnaWaveNodeUsageResponse(BaseModel):
items: List[Dict[str, Any]] = Field(default_factory=list)
class RemnaWaveBandwidth(BaseModel):
realtime_download: int
realtime_upload: int
realtime_total: int
class RemnaWaveTrafficPeriod(BaseModel):
current: int
previous: int
difference: Optional[str] = None
class RemnaWaveTrafficPeriods(BaseModel):
last_2_days: RemnaWaveTrafficPeriod
last_7_days: RemnaWaveTrafficPeriod
last_30_days: RemnaWaveTrafficPeriod
current_month: RemnaWaveTrafficPeriod
current_year: RemnaWaveTrafficPeriod
class RemnaWaveSystemSummary(BaseModel):
users_online: int
total_users: int
active_connections: int
nodes_online: int
users_last_day: int
users_last_week: int
users_never_online: int
total_user_traffic: int
class RemnaWaveServerInfo(BaseModel):
cpu_cores: int
cpu_physical_cores: int
memory_total: int
memory_used: int
memory_free: int
memory_available: int
uptime_seconds: int
class RemnaWaveSystemStatsResponse(BaseModel):
system: RemnaWaveSystemSummary
users_by_status: Dict[str, int]
server_info: RemnaWaveServerInfo
bandwidth: RemnaWaveBandwidth
traffic_periods: RemnaWaveTrafficPeriods
nodes_realtime: List[Dict[str, Any]] = Field(default_factory=list)
nodes_weekly: List[Dict[str, Any]] = Field(default_factory=list)
last_updated: Optional[datetime] = None
class RemnaWaveSquad(BaseModel):
uuid: str
name: str
members_count: int
inbounds_count: int
inbounds: List[Dict[str, Any]] = Field(default_factory=list)
class RemnaWaveSquadListResponse(BaseModel):
items: List[RemnaWaveSquad]
total: int
class RemnaWaveSquadCreateRequest(BaseModel):
name: str
inbound_uuids: List[str] = Field(default_factory=list)
class RemnaWaveSquadUpdateRequest(BaseModel):
name: Optional[str] = None
inbound_uuids: Optional[List[str]] = None
class RemnaWaveSquadActionRequest(BaseModel):
action: Literal["add_all_users", "remove_all_users", "delete", "rename", "update_inbounds"]
name: Optional[str] = None
inbound_uuids: Optional[List[str]] = None
class RemnaWaveOperationResponse(BaseModel):
success: bool
detail: Optional[str] = None
data: Optional[Dict[str, Any]] = None
class RemnaWaveInboundsResponse(BaseModel):
items: List[Dict[str, Any]] = Field(default_factory=list)
class RemnaWaveUserTrafficResponse(BaseModel):
telegram_id: int
used_traffic_bytes: int
used_traffic_gb: float
lifetime_used_traffic_bytes: int
lifetime_used_traffic_gb: float
traffic_limit_bytes: int
traffic_limit_gb: float
subscription_url: Optional[str] = None
class RemnaWaveSyncFromPanelRequest(BaseModel):
mode: Literal["all", "new_only", "update_only"] = "all"
class RemnaWaveGenericSyncResponse(BaseModel):
success: bool
detail: Optional[str] = None
data: Optional[Dict[str, Any]] = None
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class SubscriptionResponse(BaseModel):
id: int
user_id: int
status: str
actual_status: str
is_trial: bool
start_date: datetime
end_date: datetime
traffic_limit_gb: int
traffic_used_gb: float
device_limit: int
autopay_enabled: bool
autopay_days_before: int
subscription_url: Optional[str] = None
subscription_crypto_link: Optional[str] = None
connected_squads: List[str] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class SubscriptionCreateRequest(BaseModel):
user_id: int
is_trial: bool = False
duration_days: Optional[int] = None
traffic_limit_gb: Optional[int] = None
device_limit: Optional[int] = None
squad_uuid: Optional[str] = None
connected_squads: Optional[List[str]] = None
class SubscriptionExtendRequest(BaseModel):
days: int = Field(..., gt=0)
class SubscriptionTrafficRequest(BaseModel):
gb: int = Field(..., gt=0)
class SubscriptionDevicesRequest(BaseModel):
devices: int = Field(..., gt=0)
class SubscriptionSquadRequest(BaseModel):
squad_uuid: str
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class TicketMessageResponse(BaseModel):
id: int
user_id: int
message_text: str
is_from_admin: bool
has_media: bool
media_type: Optional[str] = None
media_caption: Optional[str] = None
created_at: datetime
class TicketResponse(BaseModel):
id: int
user_id: int
title: str
status: str
priority: str
created_at: datetime
updated_at: datetime
closed_at: Optional[datetime] = None
user_reply_block_permanent: bool
user_reply_block_until: Optional[datetime] = None
messages: List[TicketMessageResponse] = Field(default_factory=list)
class TicketStatusUpdateRequest(BaseModel):
status: str
class TicketPriorityUpdateRequest(BaseModel):
priority: str
class TicketReplyBlockRequest(BaseModel):
permanent: bool = False
until: Optional[datetime] = None
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class TokenResponse(BaseModel):
id: int
name: str
prefix: str = Field(..., description="Первые символы токена для идентификации")
description: Optional[str] = None
is_active: bool
created_at: datetime
updated_at: Optional[datetime] = None
expires_at: Optional[datetime] = None
last_used_at: Optional[datetime] = None
last_used_ip: Optional[str] = None
created_by: Optional[str] = None
class TokenCreateRequest(BaseModel):
name: str
description: Optional[str] = None
expires_at: Optional[datetime] = None
class TokenCreateResponse(TokenResponse):
token: str = Field(..., description="Полное значение токена (возвращается один раз)")
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class TransactionResponse(BaseModel):
id: int
user_id: int
type: str
amount_kopeks: int
amount_rubles: float
description: Optional[str] = None
payment_method: Optional[str] = None
external_id: Optional[str] = None
is_completed: bool
created_at: datetime
completed_at: Optional[datetime] = None
class TransactionListResponse(BaseModel):
items: list[TransactionResponse]
total: int
limit: int
offset: int
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class PromoGroupSummary(BaseModel):
id: int
name: str
server_discount_percent: int
traffic_discount_percent: int
device_discount_percent: int
apply_discounts_to_addons: bool = True
class SubscriptionSummary(BaseModel):
id: int
status: str
actual_status: str
is_trial: bool
start_date: datetime
end_date: datetime
traffic_limit_gb: int
traffic_used_gb: float
device_limit: int
autopay_enabled: bool
autopay_days_before: int
subscription_url: Optional[str] = None
subscription_crypto_link: Optional[str] = None
connected_squads: List[str] = Field(default_factory=list)
class UserResponse(BaseModel):
id: int
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
status: str
language: str
balance_kopeks: int
balance_rubles: float
referral_code: Optional[str] = None
referred_by_id: Optional[int] = None
has_had_paid_subscription: bool
has_made_first_topup: bool
created_at: datetime
updated_at: datetime
last_activity: Optional[datetime] = None
promo_group: Optional[PromoGroupSummary] = None
subscription: Optional[SubscriptionSummary] = None
class UserListResponse(BaseModel):
items: List[UserResponse]
total: int
limit: int
offset: int
class UserCreateRequest(BaseModel):
telegram_id: int
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
language: str = "ru"
referred_by_id: Optional[int] = None
promo_group_id: Optional[int] = None
class UserUpdateRequest(BaseModel):
username: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
language: Optional[str] = None
status: Optional[str] = None
promo_group_id: Optional[int] = None
referral_code: Optional[str] = None
has_had_paid_subscription: Optional[bool] = None
has_made_first_topup: Optional[bool] = None
class BalanceUpdateRequest(BaseModel):
amount_kopeks: int
description: Optional[str] = Field(default="Корректировка через веб-API")
create_transaction: bool = True
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import asyncio
import logging
from typing import Optional
import uvicorn
from app.config import settings
from .app import create_web_api_app
logger = logging.getLogger(__name__)
class WebAPIServer:
"""Асинхронный uvicorn-сервер для административного API."""
def __init__(self) -> None:
self._app = create_web_api_app()
workers = max(1, int(settings.WEB_API_WORKERS or 1))
if workers > 1:
logger.warning("WEB_API_WORKERS > 1 не поддерживается в embed-режиме, используем 1")
workers = 1
self._config = uvicorn.Config(
app=self._app,
host=settings.WEB_API_HOST,
port=int(settings.WEB_API_PORT or 8080),
log_level=settings.LOG_LEVEL.lower(),
workers=workers,
lifespan="on",
)
self._server = uvicorn.Server(self._config)
self._task: Optional[asyncio.Task[None]] = None
async def start(self) -> None:
if self._task and not self._task.done():
logger.info("🌐 Административное веб-API уже запущено")
return
async def _serve() -> None:
try:
await self._server.serve()
except Exception as error: # pragma: no cover - логируем ошибки сервера
logger.exception("❌ Ошибка работы веб-API: %s", error)
logger.info(
"🌐 Запуск административного API на %s:%s",
settings.WEB_API_HOST,
settings.WEB_API_PORT,
)
self._task = asyncio.create_task(_serve(), name="web-api-server")
started_attr = getattr(self._server, "started", None)
started_event = getattr(self._server, "started_event", None)
if isinstance(started_attr, asyncio.Event):
await started_attr.wait()
elif isinstance(started_event, asyncio.Event):
await started_event.wait()
else:
while not getattr(self._server, "started", False):
if self._task.done():
break
await asyncio.sleep(0.1)
if self._task.done() and self._task.exception():
raise self._task.exception()
async def stop(self) -> None:
if not self._task:
return
logger.info("🛑 Остановка административного API")
self._server.should_exit = True
await self._task
self._task = None
+1
View File
@@ -72,6 +72,7 @@ services:
# Логотип для сообщений
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
networks:
+1
View File
@@ -72,6 +72,7 @@ services:
# Логотип для сообщений
- ./vpn_logo.png:/app/vpn_logo.png:ro
ports:
- "${WEB_API_PORT:-8080}:8080"
- "${TRIBUTE_WEBHOOK_PORT:-8081}:8081"
- "${YOOKASSA_WEBHOOK_PORT:-8082}:8082"
networks:
+195
View File
@@ -0,0 +1,195 @@
# Интеграция веб-админки
Этот документ описывает запуск встроенного административного веб-API бота и типовой сценарий интеграции c внешней веб-админкой.
API разворачивается вместе с ботом, использует FastAPI и защищено токенами доступа.
## 1. Обзор архитектуры
- Веб-API запускается в том же процессе, что и бот, через встроенный `uvicorn` сервер.
- Авторизация выполняется по токену: `X-API-Key` или `Authorization: Bearer <token>`.
- Все эндпоинты работают поверх HTTPS/HTTP и возвращают структуры в формате JSON.
- Встроенный механизм миграций создаёт таблицу `web_api_tokens` и бутстрап-токен, если указан в конфигурации.
## 2. Настройка окружения
Добавьте переменные в `.env` (или другую систему конфигурации):
| Переменная | Назначение | Значение по умолчанию / пример |
|------------|------------|---------------------------------|
| `WEB_API_ENABLED` | Включает веб-API. | `true`
| `WEB_API_HOST` | IP/hostname, на котором слушает API. | `0.0.0.0`
| `WEB_API_PORT` | Порт веб-API. | `8080`
| `WEB_API_ALLOWED_ORIGINS` | Список доменов для CORS, через запятую. `*` разрешит всё. | `https://admin.example.com`
| `WEB_API_DOCS_ENABLED` | Включить `/docs` и `/openapi.json`. В проде лучше `false`. | `false`
| `WEB_API_WORKERS` | Количество воркеров uvicorn. В embed-режиме всегда приводится к `1`. | `1`
| `WEB_API_REQUEST_LOGGING` | Логировать каждый запрос API. | `true`
| `WEB_API_DEFAULT_TOKEN` | Бутстрап-токен, который будет создан при миграции. | `super-secret-token`
| `WEB_API_DEFAULT_TOKEN_NAME` | Отображаемое имя созданного токена. | `Bootstrap Token`
| `WEB_API_TOKEN_HASH_ALGORITHM` | Алгоритм хеширования токенов (`sha256`, `sha512`, ...). | `sha256`
> ⚠️ Если вы храните конфигурацию в Kubernetes/Ansible/других системах — не забудьте обновить секреты, чтобы бот видел эти переменные.
### Включение Swagger (интерактивной документации)
Чтобы открыть интерфейс Swagger UI на `/docs`, убедитесь, что одновременно заданы две переменные окружения:
1. `WEB_API_ENABLED=true` — включает само веб-API.
2. `WEB_API_DOCS_ENABLED=true` — публикует `/docs`, `/redoc` и `/openapi.json`.
После изменения значений перезапустите бота. Интерфейс будет доступен по адресу `http://<WEB_API_HOST>:<WEB_API_PORT>/docs`.
## 3. Подготовка базы данных
1. Убедитесь, что настройки БД верны (`DATABASE_URL` или параметры PostgreSQL/SQLite).
2. При старте бота автоматически запускается универсальная миграция `run_universal_migration`, которая:
- создаёт таблицу `web_api_tokens`, если её нет;
- активирует токен из `WEB_API_DEFAULT_TOKEN`, если он задан.
3. Если нужно запустить миграцию вручную, выполните:
```bash
python -c "import asyncio; from app.database.universal_migration import run_universal_migration; asyncio.run(run_universal_migration())"
```
Или просто запустите `python main.py` — бот выполнит ту же процедуру автоматически.
## 4. Запуск веб-API
```bash
# Создаём .env и включаем веб-API
cp .env.example .env
nano .env # проставьте WEB_API_* переменные и BOT_TOKEN
# Запускаем бота (локально)
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python main.py
```
В Docker достаточно пробросить порт `WEB_API_PORT` из контейнера бота. После запуска API будет доступно по адресу `http://<WEB_API_HOST>:<WEB_API_PORT>`.
## 5. Аутентификация и токены
- Первый токен удобно задать через `WEB_API_DEFAULT_TOKEN`. Он появится в таблице при запуске миграции и будет автоматически
пересоздан/активирован после изменения значения через интерфейс настроек.
- Для управления токенами используйте эндпоинты `/tokens`:
- `GET /tokens` — список токенов.
- `POST /tokens` — создать новый токен. Возвращает открытое значение один раз.
- `POST /tokens/{id}/revoke` и `/activate` — управление статусом.
- `DELETE /tokens/{id}` — удаление.
- Заголовок авторизации можно передавать двумя способами:
```http
X-API-Key: <ваш_токен>
# или
Authorization: Bearer <ваш_токен>
```
Пример запроса на создание токена:
```bash
curl -X POST "http://127.0.0.1:8080/tokens" \
-H "X-API-Key: super-secret-token" \
-H "Content-Type: application/json" \
-d '{"name": "Web admin", "description": "UI token"}'
```
## 6. Основные эндпоинты
| Метод | Путь | Назначение |
|-------|------|------------|
| `GET` | `/health` | Статус API, версия бота, флаги включённых сервисов.
| `GET` | `/stats/overview` | Сводная статистика по пользователям, подпискам, платежам и тикетам.
| `GET` | `/settings/categories` | Категории системных настроек.
| `GET` | `/settings` | Полный список настроек (с текущими и дефолтными значениями).
| `GET` | `/settings/{key}` | Получить одну настройку.
| `PUT` | `/settings/{key}` | Обновить значение настройки.
| `DELETE` | `/settings/{key}` | Сбросить настройку к значению по умолчанию.
| `GET` | `/users` | Список пользователей с фильтрами и пагинацией.
| `GET` | `/users/{id}` | Детали пользователя.
| `POST` | `/users` | Создать пользователя (например, для ручной выдачи доступа).
| `PATCH` | `/users/{id}` | Обновить профиль пользователя или статус.
| `POST` | `/users/{id}/balance` | Корректировка баланса с созданием транзакции.
| `GET` | `/subscriptions` | Список подписок с фильтрами.
| `POST` | `/subscriptions` | Создать триальную или платную подписку.
| `POST` | `/subscriptions/{id}/extend` | Продлить подписку на N дней.
| `POST` | `/subscriptions/{id}/traffic` | Добавить трафик (ГБ).
| `POST` | `/subscriptions/{id}/devices` | Добавить устройства.
| `POST` | `/subscriptions/{id}/squads` | Привязать сквад.
| `DELETE` | `/subscriptions/{id}/squads/{uuid}` | Удалить сквад.
| `GET` | `/transactions` | История транзакций.
| `GET` | `/tickets` | Список тикетов поддержки.
| `GET` | `/tickets/{id}` | Тикет с перепиской.
| `POST` | `/tickets/{id}/status` | Изменить статус тикета.
| `POST` | `/tickets/{id}/priority` | Изменить приоритет.
| `POST` | `/tickets/{id}/reply-block` | Заблокировать ответы пользователя.
| `DELETE` | `/tickets/{id}/reply-block` | Снять блокировку.
| `GET` | `/promo-groups` | Список промо-групп с количеством участников.
| `POST` | `/promo-groups` | Создать промо-группу.
| `PATCH` | `/promo-groups/{id}` | Обновить промо-группу.
| `DELETE` | `/promo-groups/{id}` | Удалить промо-группу.
| `GET` | `/tokens` | Управление токенами доступа.
### RemnaWave интеграция
После включения веб-API в Swagger (`WEB_API_DOCS_ENABLED=true`) появится раздел **remnawave**. Он объединяет эндпоинты для управления панелью RemnaWave и синхронизации данных бота:
| Метод | Путь | Назначение |
|-------|------|------------|
| `GET` | `/remnawave/status` | Проверка конфигурации и доступности RemnaWave API. |
| `GET` | `/remnawave/system` | Агрегированная статистика по пользователям, нодам и трафику. |
| `GET` | `/remnawave/nodes` | Список нод и их текущее состояние. |
| `GET` | `/remnawave/nodes/realtime` | Текущая загрузка нод (realtime-метрики RemnaWave). |
| `GET` | `/remnawave/nodes/{uuid}` | Детальная информация по конкретной ноде. |
| `GET` | `/remnawave/nodes/{uuid}/statistics` | Агрегированная статистика и история нагрузок по ноде. |
| `GET` | `/remnawave/nodes/{uuid}/usage` | История использования ноды пользователями за выбранный период. |
| `POST` | `/remnawave/nodes/{uuid}/actions` | Включение, отключение или перезапуск ноды. |
| `POST` | `/remnawave/nodes/restart` | Массовый перезапуск всех нод в RemnaWave. |
| `GET` | `/remnawave/squads` | Список внутренних сквадов с составом и статистикой. |
| `GET` | `/remnawave/squads/{uuid}` | Детали выбранного сквада. |
| `POST` | `/remnawave/squads` | Создание нового сквада и привязка inbounds. |
| `PATCH` | `/remnawave/squads/{uuid}` | Обновление имени или состава inbounds сквада. |
| `POST` | `/remnawave/squads/{uuid}/actions` | Массовые операции: добавить/удалить всех, переименовать, обновить inbounds, удалить. |
| `GET` | `/remnawave/inbounds` | Список доступных inbounds в панели RemnaWave. |
| `GET` | `/remnawave/users/{telegram_id}/traffic` | Использование трафика конкретного пользователя RemnaWave. |
| `POST` | `/remnawave/sync/from-panel` | Синхронизация пользователей и подписок из панели в бота. |
| `POST` | `/remnawave/sync/to-panel` | Обратная синхронизация данных бота в панель. |
| `POST` | `/remnawave/sync/subscriptions/validate` | Проверка и восстановление подписок в RemnaWave. |
| `POST` | `/remnawave/sync/subscriptions/cleanup` | Очистка «осиротевших» подписок и пользователей в RemnaWave. |
| `POST` | `/remnawave/sync/subscriptions/statuses` | Приведение статусов подписок в боте и панели к единому виду. |
| `GET` | `/remnawave/sync/recommendations` | Рекомендации по синхронизации: что добавить, обновить или удалить. |
> Все списковые эндпоинты поддерживают пагинацию (`limit`, `offset`) и фильтры, описанные в OpenAPI спецификации. Если `WEB_API_DOCS_ENABLED=true`, документация доступна по `/docs`. В ответах `/settings` поле `choices` всегда массив: пустой список означает отсутствие предопределённых значений.
## 7. Сценарий интеграции веб-админки
1. **Health-check** — перед авторизацией UI вызывает `GET /health`, чтобы отобразить статус и версию бота.
2. **Настройки UI** — подгружает категории через `GET /settings/categories`, далее выводит форму со значениями из `GET /settings`.
3. **Статистика дашборда**`GET /stats/overview` для карточек с показателями.
4. **Раздел пользователи**`GET /users` с поиском (`search`), фильтрами по статусу или промо-группе. Для детальной карточки использовать `GET /users/{id}`.
5. **Операции с подпиской** — использовать `POST /subscriptions/{id}/...` эндпоинты для продления, выдачи трафика и устройств.
6. **Поддержка** — список тикетов (`GET /tickets`), изменение статуса (`POST /tickets/{id}/status`), блокировка ответов (`POST /tickets/{id}/reply-block`).
7. **История операций**`GET /transactions` с фильтрами по пользователю, типу и периоду.
## 8. CORS, безопасность и логирование
- Разрешённые домены указываются в `WEB_API_ALLOWED_ORIGINS`. Для нескольких доменов перечислите их через запятую.
- Для продакшена рекомендуется отключить публичную документацию (`WEB_API_DOCS_ENABLED=false`).
- `WEB_API_REQUEST_LOGGING=true` добавляет middleware, которое логирует метод, путь и статус ответа. Используйте его для аудита или отключите в продакшене, если хватает reverse-proxy логов.
- Все токены хранятся в базе в хешированном виде. Не храните открытые значения в коде.
## 9. Диагностика проблем
| Симптом | Возможная причина | Что проверить |
|---------|------------------|---------------|
| 401 Unauthorized | Неверный или просроченный токен. | Пересоздайте токен через `/tokens` и обновите UI. |
| 403/404 при работе с настройками | Неверный ключ настройки. | Получите список доступных ключей через `GET /settings`. |
| 422 Unprocessable Entity | Неверный тип данных в теле запроса. | Проверьте типы (числа, булевы, строки) и формат JSON. |
| API не стартует | Порт занят или неверные переменные окружения. | Проверьте логи контейнера бота и значения `WEB_API_HOST/PORT`. |
## 10. Рекомендации по эксплуатации
- Регулярно ревизируйте список активных токенов и отключайте неиспользуемые.
- Для внешних админок размещайте API за reverse-proxy (nginx, Caddy, Traefik) с TLS.
- Включите мониторинг доступности (например, curl на `/health`) в систему наблюдения.
- Обновляйте бота и админку синхронно, чтобы использовать новые поля API.

Some files were not shown because too many files have changed in this diff Show More